0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

📋 セッション開始前に「今日の現在地」を20行で自動要約するdaily-briefフック

0
Posted at

大学生のころ月10万円の副業から始まり、掛け持ちで月60万円まで伸ばし、会社都合の解雇でいったんゼロに戻った。そこから半年でClaude Codeの自律環境を組み上げ、今は月商120万円。その差を生んだのは個別のアイデアではなく、「毎朝8時に現在地を自動で教えてくれる仕組み」 を先に作ったことだった。

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

Claude Codeは会話セッション単位で動く。ブラウザのタブを閉じた瞬間に前のセッションの文脈は消える。次に開いたとき、Claudeは白紙の状態で「はじめまして」から始まる。

これが地味に致命的だ。

たとえば昨日「autolikeのGumroad webhookがステージングで通らない」という問題を途中まで掘り下げていたとする。翌朝Claude Codeを立ち上げて「続きをやろう」と思っても、Claudeは昨日の文脈を持っていない。「autolikeとは?」「webhookとは?」「どこで止まっていたか?」を一から説明し直す羽目になる。1往復で2〜3分、それが毎日積み重なると週に30分以上が「説明の往復」に消える。

しかし本当の問題は時間だけではない。説明の精度が毎回ブレること だ。昨日の自分が把握していた「未コミットの変更が3ファイルある」「本番probeが500を返している」「コスト残量が今週限界に近い」という具体的な状態は、今日の自分の記憶からすでに薄れている。Claudeに伝えられる情報が粗くなるほど、Claudeの初動も粗くなる。

この問題の根本は、作業ではなく環境 にある。

個人開発で月商を積み上げるには、毎朝「今日はどこから動くか」を瞬時に判断できる状態が必要だ。10個のリポジトリがどの状態にあるか、4つのプロダクトが昨夜も本番で生きているか、今週のAPI消費コストがどこまできているか——これらを毎朝手動で確認していたら、それだけで30分が消える。自動化したとしても、その結果をClaude Codeに毎回コピー&ペーストするなら構造的に同じ問題だ。

解決策は「コンテキスト注入の自動化」 だ。Claudeがセッションを開始するたびに、環境の現在地を記述した20〜30行のサマリが自動でシステムプロンプトに差し込まれるようにする。Claudeは白紙ではなく、「今日の状態」を持った状態でセッションを始める。

この仕組みを作ったのが daily-brief.sh(環境の現在地を収集するスクリプト)と cc-brief.sh(収集した情報をClaude Codeのセッションに注入するスクリプト)の組み合わせだ。前者がデータを作り、後者がClaudeに届ける。この二段構造が、毎朝の「説明の往復」を丸ごと消した。

何を「現在地」と定義するか

仕組みを作る前に悩んだのが、「何を載せるか」の設計だった。詰め込みすぎると注入コンテキストが膨らみ、Claudeが処理するトークンが増えてコストが上がる。削りすぎると意味がない。

実際のスクリプトを観察すると、7つのカテゴリ に整理されていることがわかる。

1. ライブ疎通(本番プロダクトが生きているか)
プロダクトのURLに curl で疎通確認し、HTTPステータスを取得する。ただし単発では誤報になることがある。コールドスタートが遅いVercelのデプロイでは初回リクエストがタイムアウトし、生きているプロダクトが「死んでいる」と誤検知されることがあった。これを回避するため、スクリプトは3回リトライ・15秒タイムアウトという設計になっている。

for attempt in 1 2 3; do
  code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 "$url" 2>/dev/null || echo 000)
  if [ "$code" = "200" ] || [ "${code:0:1}" = "3" ]; then break; fi
  sleep 2
done

ここで確認しているプロダクトは就活トラッカー・就活ナビ・卒業プランナー・AETHERIA RPGの4つ。さらにautolikeのライセンスAPIに対してはダミーキー HEALTHCHECK-DUMMY でPOSTし、「サーバー設定エラー」が返るか「ライセンスが見つかりません」が返るかで環境変数の設定状態を判別する。この区別が重要で、前者はGumroadへの接続が壊れており課金フローが止まっていることを意味する。

2. 改善ログ(昨日何件の自律改善が記録されたか)

YESTERDAY=$(date -v-1d +%Y-%m-%d)
YESTERDAY_COUNT=$(grep -cE "^## ${YESTERDAY} " ~/.claude/improvements/log.md 2>/dev/null || echo 0)

improvements/log.md はClaude Codeが自律的に環境を改善したときのログだ。昨日の件数と累計件数を毎朝見ることで、「自律サイクルが回っているか」を一目で確認できる。

3. hookレイテンシ(直近24時間)
Claude Codeのhookが想定時間内に完了しているかを確認する。hookが遅延するとセッション開始が詰まるため、24時間の遅延分布をブリーフに載せる。

4. APIコスト(直近7日)
cost-summary.sh が直近7日分の消費コストを集計する。コストが週間上限に近づいていれば、その日の作業量や委譲先(CodexかSonnetか)の判断を変える。

5. launchd稼働状況(自動化タスクが動いているか)

launchctl list | grep com.shun | awk '{printf "- %s (last exit=%s)\n", $3, $2}' | head -15

毎日決まった時刻に走るlaunchdジョブが正常終了しているかを確認する。last exitが0以外なら何かが壊れているサインだ。

6. gitステータス(10リポジトリの現在地)

for repo in \
  ~/Documents/projects/closet-os \
  ~/dev/shukatsu-tracker \
  ~/dev/seo-affiliate-site \
  ~/fantasy-mmo \
  ~/hosei-grad-planner \
  ~/Projects/autolike-license-server \
  ~/Projects/instagram-auto-liker \
  ~/Projects/x-like-demo \
  ~/dev/jobform-autofill \
  ~/lead-finder; do
  ...
  echo "- **${name}** [${branch}]: ${uncommitted} 未コミット — _${last_commit}_"
done

10個のリポジトリについて、ブランチ名・未コミットファイル数・最終コミット要約を1行で出力する。「どのリポジトリで昨日作業が止まったか」が一覧で見える。

7. auto-skillの状況(自律スキルが何個生成されているか)
Claude Codeが自分でスキルを生成する仕組みを運用しているため、現在の件数と最終harvest日時をブリーフに含める。

何を省いているか

実はこのブリーフに タスク一覧は含まれていない。当初はTodoリストも差し込もうとしたが、やめた理由が2つある。

1つ目は更新コストだ。タスクは会話の中で生まれ・消え・優先度が変わる。静的なファイルに書いたタスクリストはすぐに「今日の現実」と乖離する。

2つ目はClaudeの役割の問題だ。「今日のタスクはこれです」とあらかじめ注入してしまうと、Claudeはそのリストに引っ張られる。その日の本当の優先事項を改めて議論する余地が狭まる。ブリーフは「状態の描写」であって「指示の先取り」ではない。これが設計の肝だ。

同様に、Obsidianのメモの全文も含めない。hot.md の先頭30行だけを cc-brief.sh で読み込む形にしている。Obsidianのvaultは数千のノートを持つため、全文注入はトークンの無駄遣いになる。「今週何が動いているか」の要点だけをhot.mdに集約しておき、そこだけを読む設計だ。

全体の流れ

2つのスクリプトがどう連携するかを整理する。

[launchd]
  │ 毎日 8:00 / 10:30 / ログイン時
  ▼
[daily-brief.sh]
  ├─ probe_url() × 4プロダクト(3回リトライ・max-time 15s)
  ├─ probe_autolike() × 2(instagram / x)
  ├─ probe_scout() ── github-scout-latest.md の mtime + 行数チェック
  ├─ probe_affiliate_audit() ── audit-YYYY-MM-DD.log 読み取り
  ├─ automation-health.sh(timeout 60s)
  ├─ hook-latency-report.sh(直近24h・timeout 60s)
  ├─ cost-summary.sh(直近7d・timeout 60s)
  ├─ launchctl list | grep com.shun
  ├─ git log + status × 10リポジトリ
  ├─ improvements/log.md(昨日/累計件数)
  ├─ auto-skills 件数
  └─ ディスク使用量(~/.claude・disabled-cache)
       │
       ├─→ ~/.claude/logs/daily-brief-latest.md   ←── cc-brief.sh が参照
       ├─→ ~/Desktop/Daily Brief/today-brief-*.md
       └─→ vault/wiki/briefs/daily/today-brief-*.md
            (マーカー <!-- daily-brief YYYYMMDD --> で二重追記防止)

[UserPromptSubmit フック]
  │ Claude Code の全セッション・メッセージ送信ごとに自動起動
  ▼
[cc-brief.sh]
  ├─ ccusage blocks --active --json
  │     → "5h block: {OUT_K}k out / {REMAIN}min left / ≈${COST} API-equiv"
  ├─ ccusage weekly --json
  │     → "Week: {WOUT_M}M out / ≈${WCOST} API-equiv"
  ├─ settings.json → plugins / permissions / hooks の件数
  ├─ claude mcp list → Connected / Failed / Needs-auth の件数
  ├─ improvements/log.md → 直近1エントリ(tail -r で末尾から取得)
  ├─ MEMORY.md → 末尾12行
  └─ hot.md → 先頭30行
       │
       ▼
  [Claude Code の system-reminder に注入]
  → Claudeは白紙でなく「今日の現在地」を持ってセッションを開始する

daily-brief.shの「子プロセスハング対策」

daily-brief.sh の内部では、automation-health.shhook-latency-report.shcost-summary.sh という3つの子スクリプトを呼び出す。これらが何らかの理由でハングすると、ブリーフ全体の生成が止まる。明け方にソケットが死んでいた場合や、外部APIへの接続待ちで詰まるケースが実際に起きた。

これを防ぐため、スクリプトは子プロセス呼び出しを 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=15 "$s" "$@"; else "$@"; fi; }

各子スクリプトは run_to 60 ... 2>&1 | head -10 という形で呼ばれる。60秒以内に終わらなければkillし、さらに15秒後には強制終了する。出力は head -10 で切り詰めるため、子スクリプトが大量の出力を出しても注入コンテキストが膨張しない。

cc-brief.shの「5hブロック残量」

Claude CodeはMAXプランで動かしており、ccusage コマンドで5時間ブロックのトークン残量を取得できる。

ACTIVE=$("$CCUSAGE" blocks --active --json 2>/dev/null | jq '.blocks[] | select(.isActive)')
if [ -n "$ACTIVE" ]; then
  OUT=$(echo "$ACTIVE" | jq -r '.tokenCounts.outputTokens')
  REMAIN=$(echo "$ACTIVE" | jq -r '.projection.remainingMinutes')
  COST=$(echo "$ACTIVE" | jq -r '.costUSD | floor')
  OUT_K=$((OUT / 1000))
  echo "5h block: ${OUT_K}k out / ${REMAIN}min left / ≈\$${COST} API-equiv"
fi

outputTokens を1000で割ってkトークン単位で表示し、projection.remainingMinutes でブロック内の残り分数を出す。これがセッション開始時点でわかると、「今から重い横断調査を始めていいか」の判断が瞬時にできる。残り30分なら軽いタスクに絞り、重い調査は次のブロックに回す。

週間集計は ccusage weekly --json から取得し、出力トークンをMB単位(小数点1桁)に丸めて表示する。

WOUT_M=$(jq -n --argjson n "$WOUT" '$n / 1000000 | . * 10 | round / 10')
echo "Week: ${WOUT_M}M out / ≈\$${WCOST} API-equiv"

cc-brief.shの「MEMORY.md末尾12行」

cc-brief.sh がMEMORY.mdから末尾12行だけを読む設計は意図的なトリミングだ。

[ -f "$HOME/.claude/projects/$(echo $HOME | sed 's|/|-|g')/memory/MEMORY.md" ] && \
  cat "$HOME/.claude/projects/$(echo $HOME | sed 's|/|-|g')/memory/MEMORY.md" | tail -12

MEMORY.mdは追記式のインデックスで、新しいメモリほど末尾に追加される。つまり末尾12行は「最近更新されたメモリ」を意味する。全行を読めば完全だが、トークンが膨らむ。直近のコンテキストだけを注入することで、Claudeは今週の作業に関連する記憶だけを持ってセッションを開始する

launchdのplist設計——なぜ /bin/bash 直起動か

daily-brief.sh のコメントに記載があるが、launchdのplistで実行する際は /bin/zsh 経由ではなく /bin/bash を直接指定している。

# Desktop / ~/Documents(vault) は TCC 保護領域 → plist は /bin/bash 直起動(FDA付与済み)。
# /bin/zsh 経由だと FDA 未付与で書き込みに失敗する。

Appleのフルディスクアクセス(FDA)はプロセス単位で付与される。/bin/bash にFDAを付与していても、/bin/zsh 経由で起動されたプロセスは別扱いになりDesktopやDocumentsへの書き込みが拒否される。この罠にはまった経験からplistには ProgramArguments/bin/bashdaily-brief.sh の絶対パスを直接並べる設計になっている。

また、launchdが動く環境はターミナルとはPATHが異なる。homebrew のコマンドを使うため、スクリプト冒頭で明示的にPATHを補完している。

export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"

これを忘れると timeoutjq が「コマンドが見つからない」で落ちる。launchdスクリプトのデバッグで時間を食う原因の定番がこれだ。

出力先が3箇所ある理由

daily-brief.sh は生成したブリーフを3箇所に書き出す。

  • ~/.claude/logs/daily-brief-latest.md ——cc-brief.shが参照する正本
  • ~/Desktop/Daily Brief/today-brief-YYYYMMDD.md ——人間が朝確認するための配達物
  • vault/wiki/briefs/daily/today-brief-YYYYMMDD.md ——Obsidianでの長期アーカイブ

Desktopへの配達は「人間の確認用」だ。ターミナルを開かなくてもFinderからブリーフが読める。Obsidianへのアーカイブは「1週間後に振り返る用」で、過去のプロダクト状態やコストの推移を参照できるようにしている。

二重追記を防ぐためのマーカー <!-- daily-brief YYYYMMDD --> が各ファイルに埋め込まれ、同じ日付のブリーフが2回書き込まれても1つに保たれる設計だ。

MARK="<!-- daily-brief ${DATE} -->"
LC_ALL=C grep -qF -- "$MARK" "$f" 2>/dev/null && continue

LC_ALL=C を付けているのは、ファイルに不正バイトが混入していてもgrep がバイナリ扱いせずマーカーを確実に検出するためだ。

最後に、ブリーフはDiscordの #04_daily-brief チャンネルにも投稿される。

[ -s "$OUT" ] && /usr/bin/python3 "$HOME/.discord/post_brief.py" daily-brief "$OUT" >/dev/null 2>&1 || true

Discordへの投稿が失敗してもブリーフ生成全体を止めないよう || true で無視する設計になっている。ブリーフの生成が主であり、Discordへの投稿は副産物という優先順位が明確だ。

実装の詳細

cc-brief.shの7つのセクション

cc-brief.sh は53行のスクリプトだが、内部は7つのセクションに分かれている。前半で触れた「USAGEセクション」以外の部分を順番に解剖する。

ENVセクション

SETTINGS="$HOME/.claude/settings.json"
echo "Plugins enabled:   $(jq -r '.enabledPlugins | length' "$SETTINGS")"
echo "Permissions allow: $(jq -r '.permissions.allow | length' "$SETTINGS")"
echo "Hooks:             $(jq -r '.hooks | keys | length' "$SETTINGS") events"

settings.json からプラグイン数・パーミッション数・フックイベント数を1行ずつ出力する。数字なのがポイントだ。「8 plugins / 43 allow / 6 events」という出力が毎朝同じなら、前夜に自分が触っていない設定が変わっていないと即座にわかる。逆に見慣れない数字が出ていれば、何かが変わったサインだ。「hookを追加したはずなのにeventsが変わっていない」という検知も、この1行から始まった。

MCPセクション

MCP=$("$HOME/.nvm/versions/node/v24.13.0/bin/claude" mcp list 2>&1 || claude mcp list 2>&1)
echo "Connected: $(echo "$MCP" | grep -c Connected) / \
Failed: $(echo "$MCP" | grep -c 'Failed to connect') / \
Need-auth: $(echo "$MCP" | grep -c 'Needs auth')"

MCPサーバーの接続状態を3カテゴリに分けて数える。注目すべきは呼び出し方だ。$HOME/.nvm/versions/node/v24.13.0/bin/claude という絶対パスを先に試し、失敗したら claude コマンドにフォールバックする。cc-brief.sh はUserPromptSubmitフックから呼ばれるため、シェルのPATHが完全に引き継がれる保証がない。絶対パスを第一候補にすることで、nvm 配下の claude バイナリが確実に使われる。

実際に Failed: 1 が出て気づいたケースがある。夜中に走ったスクリプトがMCPサーバーの設定ファイルを誤って上書きし、翌朝のセッション開始時点で接続が切れていた。claude mcp list をターミナルで手動実行していたら気づかなかった時間帯だ。セッション開始1秒で「何かがおかしい」とわかる。

RECENT IMPROVEMENTSセクション

[ -f "$HOME/.claude/improvements/log.md" ] && \
  tail -r "$HOME/.claude/improvements/log.md" 2>/dev/null \
  | awk '/^## /{count++; if(count>1) exit} {print}' \
  | tail -r \
  | head -30

このワンライナーは「improvements/log.md から最新の1エントリだけを取り出す」処理だ。構造が特徴的なので解説する。

improvements/log.md は追記式のログで、各エントリが ## YYYY-MM-DD HH:MM ヘッダで始まる。ファイル末尾が最新エントリだ。最新エントリだけを取り出すには「末尾から逆順に読んで、最初のセクション境界を見つけたら停止する」ロジックが必要になる。

tail -r はファイルを行単位で逆順に出力するmacOS固有コマンドだ(GNU系では tac)。逆順にしたうえで awk がセクション境界(## 行)を2回目に見つけた時点で出力を止める。これでファイルの末尾エントリ1つ分だけを逆順で得られる。最後に再び tail -r で正順に戻し、head -30 で切り詰める。

「なぜ grep -A 30 "最後のヘッダ" にしないのか」——ヘッダ行を特定するために一度ファイル全体を grep する必要があり、エントリ数が増えるほど遅くなる。逆順+awkは常に末尾から数行だけ読めば終わるため、ログが1000エントリを超えても速度が落ちない。

MEMORY INDEXセクション

[ -f "$HOME/.claude/projects/$(echo $HOME | sed 's|/|-|g')/memory/MEMORY.md" ] && \
  cat ... | tail -12

このパス構築には癖がある。Claude Codeはプロジェクト別のメモリを ~/.claude/projects/<パスを-でエンコードしたもの>/memory/ に保存する。たとえばホームディレクトリが /Users/alice なら、パスは -Users-alice にエンコードされる。この変換を echo $HOME | sed 's|/|-|g' が行っている。動的にパスを組み立てているため、ホームディレクトリのパスが変わっても壊れない設計だ。

OBSIDIAN HOTセクション

[ -f "$HOME/Documents/claude-obsidian/wiki/hot.md" ] && \
  head -30 "$HOME/Documents/claude-obsidian/wiki/hot.md"

ObsidianのvaultにあるHOTノートの先頭30行だけを読む。hot.md は「今週アクティブな作業の要点」を自分で更新するノートで、新規インシデント・進行中の問題・今週の重点を30行に収めるルールで運用している。先頭30行だけ注入するのは意図的なトリミングで、vaultの他のノートは全て参照対象外にすることでトークンを抑える。

daily-brief.shの細部——見落とすと壊れる3つの設計

iconv -c による UTF-8 安全化

last_commit=$(git -C "$repo" log -1 --format='%cr %s' 2>/dev/null \
  | cut -c1-70 \
  | /usr/bin/iconv -f UTF-8 -t UTF-8 -c)

cut -c はバイト単位で切り出すため、マルチバイト文字(日本語コミットメッセージなど)の途中でバイト列を分断することがある。分断された不正なUTF-8シーケンスが last_commit 変数に入ると、後続処理の LC_ALL=C grep -qF "$MARK" がファイルをバイナリとして扱い、マーカー検出に失敗する。

iconv -f UTF-8 -t UTF-8 -c は「入力をUTF-8として読み、UTF-8として出力し、変換できないバイトは無声で捨てる(-c)」という意味だ。これで途中で割れたバイト列が除去され、後続の grep が安全に動く。/usr/bin/iconv と絶対パスにしているのは、launchd環境での PATH 信頼性のためだ。

LC_ALL=C によるマーカー検出の安全化

LC_ALL=C grep -qF -- "$MARK" "$f" 2>/dev/null && continue

二重追記防止のマーカー <!-- daily-brief YYYYMMDD --> を検索する行だ。LC_ALL=C がなければ、ファイル内に不正バイト(前述のようなUTF-8分断の残骸)が1バイトでも混入していると、UTF-8ロケールのgrepはファイル全体を「バイナリとして処理できない」と判断してスキップする。その結果マーカーを検出できず、同じ日のブリーフが何度も追記される。

LC_ALL=C を付けると locale に関係なくバイト単位で比較するため、ファイルに何が入っていてもマーカーを確実に拾える。

probe_scout()grep -c footgun

probe_scout() のコメントに「既知footgun」として記載されている部分がある。

# 注: grep -c は0件でも「0」を出力し exit 1 を返す → `|| echo 0` は二重出力(0\n0)を生み
#     整数比較を壊す既知footgun。grep -c 単体で十分なので付けない。
local items; items=$(grep -cE '^- ' "$f" 2>/dev/null); items=${items:-0}

grep -c は「マッチした行数」を標準出力に出力するが、0件のときは exit 1 を返す。ここに || echo 0 を付けると何が起きるか。grep -c がすでに「0」を出力してから exit 1 で終了し、|| echo 0 がもう一度「0」を出力する。結果として変数に "0\n0" という2行が入り、[ "${items}" -lt 1 ] のような整数比較が "value too great or invalid" エラーで落ちる。

正しくは items=${items:-0} で変数が空のときだけデフォルト値を当てる。grep -c が0を出力しているときはその値をそのまま使い、grep自体が失敗(ファイルなし等)してゴミが混入したときだけ0で上書きする。


私が詰まった話

1. 明け方のソケット死でScout欄が無言で空白化した

2026年6月11日の朝、ブリーフを開いたらGitHub Scoutの欄が完全に空白だった。エラーも出ていない。ゼロ件と書いてあるわけでもなく、欄ごと消えていた。

原因を調べると、明け方に走ったScoutの巡回スクリプトが claude -p(パイプモード)でAPIを呼び出す部分で、ソケットが切れた状態で応答を待ち続け、タイムアウトなしで詰まっていた。スクリプト自体は「失敗した」と気づかないまま正常終了し、出力ファイルに空っぽの内容を書き出した。

ブリーフは cat github-scout-latest.md で内容をそのまま出力していたため、空ファイルなら空欄になる。「Scout欄がない = Scoutが動いていない」という判断が不可能な状態だった。

この事故から probe_scout() 関数が生まれた。出力ファイルの存在チェック・当日更新かどうかのmtime比較・候補行数の最低値チェック・失敗マーカーの存在チェックの4段構えで死活を判定する。

local mdate; mdate=$(date -r "$f" +%Y%m%d 2>/dev/null)
elif [ "$mdate" != "$DATE" ]; then
  echo "- ❌ **GitHub Scout**: 出力が当日更新でない(${mdate:-不明})。今朝の巡回が走ってない疑い"
  flag_red "GitHub Scout の出力が当日(${DATE})更新でない→巡回が落ちてる疑い"

mtimeが今日でなければ赤旗を立てる。空ファイルを出した場合も候補行数ゼロで赤旗になる。これで「無言の空白化」は二度と見逃せなくなった。

2. launchdのPATHで jqtimeout が消えた

daily-brief.sh の開発初期、launchdから起動すると毎回途中でスクリプトが止まった。ターミナルから手動実行すると問題なく動く。

差分はPATHだ。ターミナルで echo $PATH すると /opt/homebrew/bin が先頭に来ている。launchdが起動するシェルのPATHは /usr/bin:/bin:/usr/sbin:/sbin しか持っていない。Homebrewでインストールした jqtimeoutccusage はここに含まれないため、スクリプトが jq: command not found で落ちていた。

export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"

この1行をスクリプト冒頭に置くことで解決した。追記ではなく 先頭に付け加える のが重要だ。$PATH の末尾に足すと /usr/bin/jq(存在しない)を先に探してしまい、Homebrew版が拾われない場合がある。

launchdでしか起きないバグは「ターミナルから手動実行してみる → 動く → 困惑する」のループが長引くため、PATH問題を最初に疑う習慣をつけた。

3. フルディスクアクセスのzsh罠:Desktopへ書けない

ブリーフのDesktop配信を実装した直後、~/Desktop/Daily Brief/ への書き込みが permission denied で失敗した。launchdのplistに /bin/zsh で実行するよう書いていた。

macOSのTCC(Transparency, Consent, and Control)はフルディスクアクセスをプロセス単位で管理する。/bin/bash にFDAを付与していても、/bin/zsh 経由でサブプロセスが起動されたときはzshにFDAが付与されていなければアクセスが通らない。システム環境設定で /bin/bash をFDA対象に追加していても、plistに /bin/zsh と書いてあれば意味がない。

<!-- plist の ProgramArguments(正しい書き方) -->
<array>
  <string>/bin/bash</string>
  <string>/Users/alice/.claude/scripts/daily-brief.sh</string>
</array>

/bin/zsh/bin/bash に変えてFDAを付与し直すことで解決した。daily-brief.sh のコメントにこの罠を記録してあるのは、半年後の自分が同じ設定を再構築するときに同じ穴にはまらないためだ。

4. YESTERDAY_COUNT が「0\n0」になって比較が壊れた

改善ログの昨日件数を出す部分で、ある日突然ブリーフが integer expression expected エラーで止まった。

YESTERDAY_COUNT=$(grep -cE "^## ${YESTERDAY} " ~/.claude/improvements/log.md 2>/dev/null || echo 0)

前日のログファイルに日本語コミットメッセージが混じり、improvements/log.md の特定行に不正バイトが混入していた。そのせいで grep がファイルをバイナリと判定し、exit 1で終了。|| echo 0 が追加の「0」を出力し、変数に "0\n0" が入った。後続の echo "- 昨日の改善エントリ: ${YESTERDAY_COUNT} 件" は平和に動くが、この値を数値として比較しようとした別の箇所でエラーが出た。

対処は2段階だった。まず || echo 0 をやめて ${items:-0} に変える(前述のfootgun対策)。次に improvements/log.md の不正バイトを iconv -c で除去した。

再発防止のため probe_affiliate_audit() など他の grep -c も同じパターンに統一した。|| echo 0 という書き方をコードベースから一掃する作業が半日かかった。気づいたのがブリーフのエラーメッセージだったから救われたが、サイレントに壊れていたら永遠に気づかなかっただろう。

5. tail -r がLinuxでは動かない

cc-brief.sh の RECENT IMPROVEMENTS セクションで使っている tail -r は macOS固有の拡張だ。LinuxにはなくGNU coreutilsの tac に相当する。

tail -r "$HOME/.claude/improvements/log.md" 2>/dev/null | awk '...'

開発環境はmacOSだが、一部の自動化スクリプトを軽量なLinuxコンテナで動かそうとした際に tail: invalid option -- r で落ちた。CI環境でブリーフ生成を走らせるテストも同じ理由で動かなかった。

最終的にmac専用スクリプトとして割り切ることにしたが、スクリプト冒頭に #!/usr/bin/env bash と書きながら tail -r というmac固有機能を使うのは「読んだ人がLinuxでも動くと思ってしまう」罠だ。コメントに明記しておくことを学んだ。

移植が必要な場面では tail -r の代わりに awk '{lines[NR]=$0} END {for(i=NR;i>=1;i--) print lines[i]}' で同等の動作を実現できる。ただし行数に比例してメモリを使うため、ログが巨大なら tac コマンドの存在チェックを先に入れるべきだろう。

つまずきポイント

前半・中段で掘り下げた5つの詰まりとは別に、実際の運用で踏んだ落とし穴をまとめて列挙する。同じパターンを踏まないための手がかりとして読んでほしい。

  • ccusage が見つからないcc-brief.sh の1行目が command -v ccusage で見つからなかった場合のフォールバックを持っているのはそのためだ($HOME/.nvm/versions/node/v24.13.0/bin/ccusage を直接指定)。UserPromptSubmitフックはターミナルのPATHを引き継ぐが、フックが走るタイミングによってはnvm初期化が終わっていないことがある。command -v でまず試し、失敗したら絶対パスに落ちる2段構えが正解だ。

  • cc-brief.shの実行時間が1秒を超えてセッション開始がモタつく。UserPromptSubmitフックは同期的に実行される。claude mcp list の呼び出しに時間がかかるとその分セッション開始が遅れる。MCP接続が不安定な環境では mcp list だけで2〜3秒かかることがある。後から timeout 5 ... でラップして5秒を超えたら諦める設計にした。

  • jq '.enabledPlugins | length' が null を返してスクリプトが落ちるsettings.jsonenabledPlugins キー自体がない構成のとき、jqnull を返し、length に対して null を渡すとエラーになる。jq -r '(.enabledPlugins // []) | length' のように // [] でデフォルトを当てる修正が必要だった。

  • hot.mdの先頭30行に重要情報が入っていなかったcc-brief.shhead -30 しか読まない設計だ。「末尾に追記」する習慣でhot.mdを書いていたため、最重要のインシデント情報が31行目以降にあり、注入されていなかった。head -30 に合わせてhot.mdの運用ルールを「最重要事項は先頭に書く」に変えた。

  • MEMORY.mdへの追記順序が逆になってメモリが見えなくなったcc-brief.shtail -12 で末尾12行を読む。新しいメモリを先頭に追記するツールを作ってしまったため、最古のメモリだけが毎回注入される状態になっていた。インデックスは「末尾に追記=新しいものが末尾」の順序を守ることで初めてこの設計が機能する。

  • Discordへの投稿がタイムアウトして以降のブリーフ生成が止まるpost_brief.py の呼び出しにタイムアウト設定を入れていなかった初期バージョンで、Discordのレートリミットに引っかかった瞬間にスクリプト全体が詰まった。現在は || true で無視するだけでなく、python3 ... & でバックグラウンドに投げることを検討している。Discordへの投稿はブリーフの主目的ではなく副産物なので、本流を止める権限を与えない。

  • probe_autolike() のダミーキー文字列判定が壊れた。ダミーキー HEALTHCHECK-DUMMY でPOSTしたとき「サーバー設定エラー」vs「ライセンスが見つかりません」の文字列でGumroad接続状態を区別している。本番コードが返すエラー文言のリファクタ後、この文字列が変わり判定が逆転した。ブリーフが「接続正常」と報告する一方で実際のライセンス検証が壊れていた。エラー文言依存の死活判定は、本番コードの変更時に連動してメンテが要る。

  • daily-brief.sh 自体がlaunchdから起動されたログを確認できない~/Library/Logs/com.shun.daily-brief.logstderr をリダイレクトするplist設定を入れるまで、launchdから起動した場合のエラーが消えていた。ターミナルから手動実行すると動く・launchdから動かない、という謎の不一致がこれで解消した。launchdデバッグの第一歩はStandardErrorPath設定だ。

  • git log のコミットメッセージに絵文字が入ると cut -c1-70 が詰まる。絵文字は4バイトのUTF-8シーケンスで、cut -c(文字単位)ではなく cut -b(バイト単位)と混在させると文字幅の計算がずれる。70文字で切ったつもりが実際は絵文字の途中を分断し、後続の iconv -c が削除するという連鎖が起きた。コミットメッセージを変数に入れる部分はすべて iconv -c 後処理を前提にする設計にしてある。

  • weekly集計の jq '.weekly[-1]' が配列外参照でnullを返す。週の開始直後(月曜の深夜)にccusage weekly --jsonweekly 配列が0件になる瞬間がある。[-1] はJSONのシンタックスでは有効だがjqでは空配列に対してnullを返す。この状態で echo "$WEEK" | jq -r '.outputTokens' を叩くとnullを出力し、後続の変数に入って jq -n '$n / 1000000 ...' が型エラーで落ちた。if [ -n "$WEEK" ] && [ "$WEEK" != "null" ]; then でnull文字列もチェックする修正が必要だ。


ベストプラクティス

半年の運用と数十回の詰まりから蒸留した実践的なルールを並べる。

1. タイムアウトは二重構造にするcurl --max-time 15 で接続単体を制限し、さらに run_to 60 で子スクリプト全体を制限する。単一のタイムアウトは「通信は切れたがプロセスが残る」ケースをカバーしない。--kill-after=15 の強制SIGKILLまでセットにして初めて完全だ。

2. launchdスクリプトの1行目はPATH補完

export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"

$PATH の先頭に付ける。末尾に足すと /usr/bin/jq(存在しない)を先に探してしまい、Homebrew版が拾われない場合がある。このルールを知っていれば「ターミナルで動く・launchdで動かない」の迷路に入らずに済む。

3. plistは /bin/bash 直起動・FDAはそのバイナリに付与/bin/zsh 経由ではDesktop・Documentsへのアクセスが拒否される。TCCはプロセスバイナリ単位でFDAを管理するため、実行者と付与先が一致していなければ意味がない。

4. LC_ALL=C をgrep頭に付ける。ファイルに不正バイトが1バイトでも混入していると、UTF-8ロケールのgrepはファイル全体をバイナリ扱いしてスキップする。マーカー検出・ログ行カウントのようなパターンマッチは全て LC_ALL=C 付きにする。

5. grep -c の後ろに || echo 0 を書かないgrep -c は0件でも「0」を標準出力に出してからexit 1を返す。|| echo 0 が追加の「0」を出力し、変数に "0\n0" が入って整数比較が壊れる。正しくは count=$(grep -cE '...' file 2>/dev/null); count=${count:-0} だ。

6. マルチバイト文字を含む変数には iconv -f UTF-8 -t UTF-8 -c を通すcut -c70 で日本語コミットメッセージを途中で切ると不正バイト列が残る。iconv -c はその残骸を無声で除去する。/usr/bin/iconv と絶対パスで呼ぶこと(launchd環境でのPATH問題対策)。

7. 二重追記防止マーカーをファイルごとに埋め込む<!-- daily-brief YYYYMMDD --> のようなマーカーを書き込んでからgrepで存在チェックする。launchdで同日に複数回走ることがある(8:00・10:30・ログイン時の3回)ため、このガードがないと同じブリーフが3つ重なる。

8. エラー出力を ~/Library/Logs/ にリダイレクトするStandardErrorPathをplistに設定する。これがなければlaunchd起動のデバッグはほぼ不可能だ。ターミナルでは動くのにlaunchdでは動かない問題の原因の8割はこのログを見れば即判明する。

9. Discordなど副産物の投稿は || true でサイレント失敗させる。ブリーフの生成が主、外部投稿は副産物だという優先順位を || true でコードに表現する。さらに投稿関数にはスクリプト単体のタイムアウトを入れ、本流の時間制限とは別管理にする。

10. cc-brief.sh で呼ぶ claude バイナリは絶対パスを第一候補にする。UserPromptSubmitフックはnvmのシェル関数初期化が完了していないことがある。$HOME/.nvm/versions/node/v24.13.0/bin/claude を先に試し、失敗したら claude にフォールバックする2段構えで確実に動く。

11. hot.mdは先頭30行に最重要事項を集中させるcc-brief.shhead -30 しか読まないため、末尾への追記習慣だと最新情報が切り捨てられる。hot.mdの更新ルールは「新しいインシデントは先頭に挿入」で運用する。

12. MEMORY.mdの追記順序は「末尾追記=新しいものが末尾」を守るtail -12 で末尾を読む設計になっているため、追記順序が逆だと最古のメモリしか注入されない。メモリ追記ツールを作るときはこの方向性を固定する。

13. 出力先は正本・人間用・アーカイブの3つに分ける。正本(~/.claude/logs/daily-brief-latest.md)は自動化スクリプトが読む機械用、Desktop配信は人間が朝確認する用、Obsidianアーカイブは振り返り用と役割を分離する。それぞれが独立しているため、1箇所の書き込みが失敗しても他には影響しない。

14. tail -r はmacOS専用と明記する。GNU環境では tac に相当する。スクリプト冒頭のコメントに「このスクリプトはmacOS専用(tail -r 依存)」と書くことで、Linuxへの移植を試みた自分(または未来の自分)が即座に気づける。

15. 「ブリーフは状態の描写であって指示の先取りではない」という設計思想を守る。タスクリストをブリーフに含めると、Claudeがそのリストに引っ張られ、その日の本当の優先事項を改めて議論する余地が狭まる。ブリーフに含めるのは「今どこにいるか(環境の現在地)」だけで、「今日何をするか」はセッション開始後に毎回決める。


まとめ

daily-brief.shcc-brief.sh は、合わせても200行に満たないスクリプトだ。しかしこの2つを組み合わせることで、Claude Codeが毎回白紙から始まるという構造的な問題を根本から解決している。

解雇でゼロに戻ってから半年、毎朝30分が「今日の現在地を説明する往復」に消えていた。10個のリポジトリそれぞれの状態、4つのプロダクトの本番ステータス、APIコストの残量——これを毎回手動で確認しながらClaude Codeに伝えていたら、その時点でエネルギーの相当部分を使い果たしている。

仕組みを自動化したのは「効率化したかったから」ではない。毎朝の最初の30分を「判断」に使えるようにするためだ。ブリーフがあると、Claude Codeはセッション開始直後から「autolikeのライセンスAPIが昨夜から500を返している」「今週のAPI消費が上限の80%に達している」「hosei-grad-plannerで昨日コミットが止まっている」という具体的な状態を持っている。「今日はどこから動くか」の判断が数秒で終わる。

月商120万の内訳は、自律化した複数のプロダクトが並行して動いているからだ。その並行稼働を可能にしているのは「毎朝、全部の現在地が一目でわかる」という基盤の存在だ。ブリーフの仕組みがなければ、10個のリポジトリのうち毎朝3つしか把握できず、残り7つは「確認するコスト」を払えないまま放置される。放置は停滞であり、停滞は収益の頭打ちに直結する。

このブリーフの仕組みを先に作ったことで、その後の半年で積み上げた自律サイクル——Scoutが候補を発見し・Codexが実装し・launchdが毎夜走り・ブリーフが翌朝その結果を報告する——を回し続けられている。「仕組みを作る仕組み」の起点がここにある。


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

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


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

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?