🌾 Claudeが発見した手順を自動で刈り取るskill-harvestの仕組み
会話ログは毎晩消えていく。でも私の環境は毎晩賢くなる。
なぜこの仕組みが効くのか
副業を始めた当初、月10万円稼ぐのに毎晩3〜4時間を溶かしていました。コンテンツ量産のためにClaude Codeを使い始めてからは掛け持ち月60万円まで伸びましたが、その後会社都合で解雇されてゼロに戻りました。そこから半年、今の月商120万円を支えているのは正直なところコードそのものではなく「環境」です。
「環境」とは何か。 私の定義では、翌週の自分が今週の自分より賢い状態でスタートできる仕組みのことです。これはノウハウをメモするという話ではありません。メモはサボります。記録のために作業が中断されます。そして3週間後には見返しません。
Claude Codeとペアプロしていると、1日に何度も「あ、これは使える」という発見があります。APIのレート制限を踏み抜かずにバッチを流す方法、launchdのplistでシェルのPATHが死ぬ理由と回避策、自動化スクリプトをstaging経由でコピーしないと書き込みがブロックされる理由——これらはドキュメントに書いてある話ではなく、実際にその環境で転んで気づく非自明な知識です。
問題は、このような知識がセッションをまたいで消えることです。Claude Codeはセッション単位でコンテキストが閉じます。今日の会話で「~/.claude/ 配下はClaude Code自身が書き込み保護するのでステージング経由でコピーする必要がある」という学びを得ても、明日のセッションにその知識はありません。次に同じ壁にぶつかったとき、また同じ手間がかかります。
作業に投資しても環境は育たない
多くの人は「Claudeで作業を速くしよう」という発想で使い始めます。私もそうでした。でも作業速度には上限があります。1日24時間という物理的な壁があるからです。
環境に投資するとその上限が変わります。今週の私が100の知識でやっている作業を、来週の私が150の知識でやれば、同じ時間でより複雑な仕事ができます。スキルライブラリが200本を超えた頃から、明らかにClaude Codeへの指示の質が変わりました。「あのスキルと同じパターンで」と一言添えるだけで、文脈説明なしに非自明な手順を実行してもらえます。
ただ、この「環境への投資」を手動でやろうとすると破綻します。会話ログを読み返してメモを書く作業は、割り込みが多い副業の現場では継続できません。
Claude自身に発見させる
解決策はシンプルで、Claude Codeに「有益な手順を見つけたら自分でスキルファイルを書いておけ」というルールをCLAUDE.mdに書いておくことです。実際にそのルールがあります。
# ~/.claude/CLAUDE.md(抜粋)
## スキル自己生成(auto-skills)
再利用価値のある手順(5回以上ツールの非自明タスク完遂・回避策発見・
アプローチ修正された・再利用手順発見)は頼まれなくても
`~/.claude/skills/auto/<kebab-name>/SKILL.md` に自作する。
しかしこれだけでは不十分でした。セッション中のClaude Codeはそのセッションで発生した手順しか知りません。複数のセッションにまたがって蓄積された「昨日のこれと今日のこれが実は同じパターンだった」という気づきは、セッション単位の自動生成では拾えないのです。
そこで必要になったのが、会話ログを後から読み直してスキルを刈り取るバッチ処理です。skill-harvest.sh がその役割を担います。毎朝3時30分、自動で動いてその日の会話から非自明な手順を抽出し、~/.claude/skills/auto/ 配下にスキルファイルとして保存します。人間は何もしません。朝起きたら環境が育っています。
読者が感じる「あの感覚」
ChatGPTやClaude Codeを使いこなせている人でも、「この操作手順、前も調べたな」という感覚は残ります。検索して、試して、ようやく動いた手順が次の日には思い出せない。これは記憶力の問題ではなく、手順が適切な形式で適切な場所に保存されていないという設計の問題です。
skill-harvest.sh が解決するのはまさにこの問題です。発見した手順をその場で構造化ファイルに落とし、次回のClaude Codeセッションから自動でその知識が参照可能になる。Claudeの長期記憶の欠如を、外部ファイルシステムで補う設計です。
全体の流れ
まず全体像をアスキー図で示します。
毎晩の会話ログ (.md)
~/Documents/my-knowledge-base/raw/conversations/
│
│ find -name '*.md' -newer .harvest-watermark
↓
新着ログを最大3本選定(MAX_LOGS=3)
│
│ grep -v system-reminder | head -c 15000
↓
ダイジェスト生成(最大45KB→ノイズ除去)
│
│ claude -p --model sonnet --max-budget-usd 1.20
↓
ステージングディレクトリへ SKILL.md を生成
/tmp/skill-harvest-stg.XXXXX/
└── <kebab-name>/
└── SKILL.md
│
│ author:auto 確認 + 既存スキルとの重複チェック
↓
~/.claude/skills/auto/ へコピー
.harvest-watermark を更新
│
│ 週次(手動 or cron)
↓
skill-curate.sh による整理
├── 会話ログへの言及が30日ない → status: stale
├── 90日ない → .archive へ退避
└── 新着スキルを LLM で分析 → .curator-proposals.md(提案のみ)
launchdが毎朝3時30分にこのフローを起動します。人間の関与は不要で、提案ファイルを週に一度レビューするだけです。
launchdの設定:なぜcronではないか
com.shun.skill-harvest.plist の中身を見てみます。
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
午前3時30分という時刻は意図的です。Claude Codeセッションが終わった後、翌朝の最初のセッションが始まる前という窓を狙っています。LowPriorityIO: true と Nice: 10 の組み合わせで、macOSのIOスケジューラとCPUスケジューラに対して「これはバックグラウンドの低優先度タスクだ」と明示しています。眠っている間にファンが回るのを避けるための配慮です。
cronではなくlaunchdを使う理由は、macOSではcronがサスペンド中に起動時刻を過ぎると実行されないのに対し、launchdはスリープ復帰後に「起動されるべきだった」タスクを追いかけて実行するからです。MacBookのふたを閉じていた夜でも翌朝の復帰時に実行されます。
環境変数のPATHはplist内に明示的に設定されています。
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:
/opt/homebrew/bin:/opt/homebrew/sbin:
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:
~/.local/bin</string>
</dict>
これはlaunchdの罠として有名な問題です。launchd経由で起動するジョブは ~/.zshrc を読みません。nvm 経由でインストールしたnodeもpathが通らず、claude コマンドが見つからないとバッチが静かに終了します。実際にはスクリプト内でも export PATH=... を二重で設定しています。launchdとシェルスクリプトの両方にPATHを書くのは冗長に見えますが、どちらの経路で呼ばれても動くようにするための意図的な設計です。
ウォーターマークによる差分処理
skill-harvest.sh の最初の重要なロジックがウォーターマーク処理です。
AUTO="$HOME/.claude/skills/auto"
LOGS="$HOME/Documents/my-knowledge-base/raw/conversations"
WM="$AUTO/.harvest-watermark"
# 前回以降に更新されたログを新しい順に収集(初回は最新 MAX_LOGS 件)
if [[ -f "$WM" ]]; then
newlogs=("${(@f)$(find "$LOGS" -name '*.md' -newer "$WM" 2>/dev/null)}")
else
newlogs=("${(@f)$(ls -t "$LOGS"/*.md 2>/dev/null)}")
fi
# mtime 降順に並べ替えて上位 MAX_LOGS 件に絞る
if (( ${#newlogs} > 0 )); then
newlogs=("${(@f)$(ls -t "${newlogs[@]}" 2>/dev/null | head -$MAX_LOGS)}")
fi
.harvest-watermark というゼロバイトファイルのmtimeが「前回の実行時刻」を表します。find -newer $WM で前回以降に更新されたログだけを対象にし、head -$MAX_LOGS で最大3本に絞ります。
なぜ3本なのかというと、後続のLLM呼び出しのコスト制御のためです。MAX_LOGS=3、PER_LOG_BYTES=15000 という設定で、1回のハーベストで扱うデータ量の上限は理論値45KBです。会話ログには大量の <system-reminder> ブロック(利用可能なスキル一覧など)が含まれており、そのままLLMに渡すと大半がノイズになります。
ダイジェスト生成:ノイズをどう除くか
DIGEST=$(mktemp -t skill-harvest)
for f in "${newlogs[@]}"; do
{
echo "===== LOG: ${f:t} ====="
# 巨大な <system-reminder> ブロックを大まかに除去してからバイト上限で切る
grep -v -e 'system-reminder' -e '^- [a-z0-9].*:' "$f" 2>/dev/null \
| head -c $PER_LOG_BYTES
echo
} >> "$DIGEST"
done
grep -v -e 'system-reminder' で <system-reminder> タグを含む行を除去し、さらに '^- [a-z0-9].*:' パターンで箇条書き形式のスキル一覧行をフィルタします。これにより実際の会話内容だけが抽出されます。
1本あたり15,000バイト(約15KB)でカットするため、長い会話ログの末尾は切り捨てられます。この割り切りは意図的で、「スクリプトの先頭に近い部分ほど重要な手順が出やすい」という経験則に基づいています。実際、セッションの前半で課題設定と解法発見が起き、後半はその実装という構造になることが多いです。
LLMへのプロンプト設計:何を渡すか
生成したダイジェストをLLMに渡すプロンプトの骨格はこうなっています。
existing=$(ls "$AUTO" 2>/dev/null | grep -v '^\.' | tr '\n' ',')
PROMPT="あなたはスキルライブラリのハーベスターです。
下記の会話ログ抜粋から、将来再利用できる『手順的知識』だけをスキル化してください。
(ダイジェスト本文)
既存の auto スキル(重複作成は禁止。重複するなら新規作成せず既存を patch):
${existing:-(なし)}
抽出基準:
- 複数手順を要する非自明な作業フロー / エラー回避策 / 繰り返し使えるコマンド列
- 一度きり・自明・雑談・個人情報は対象外
- 該当が無ければ何もファイルを作らず『該当なし』とだけ述べて終了
各スキルの作り方:
- ファイル: ./<kebab-name>/SKILL.md(カレントディレクトリ直下・絶対パス禁止)
- frontmatter: name / description / author: auto / created / version: 1.0.0 / status: active
- 本文: ## Procedure / ## Pitfalls / ## Verification の3節
- 1スキル=1手順で小さく保つ"
重複排除のために既存スキルのディレクトリ名一覧を existing として渡しているのがポイントです。LLMが「このスキルはすでに codex-delegation-handoff として存在するから新規作成不要」という判断を自分でできるようになります。
また description(いつ発火すべきか) というfrontmatterの項目名を見てください。これはClaude Codeがどのタイミングでそのスキルを使うべきかを制御するメタデータです。「launchdのplistを作成するとき」「新しいnpmパッケージをインストールするとき」のように発火条件が書かれていれば、Claude Codeが類似タスクに着手した際に自動でそのスキルを参照します。
ステージングパターン:なぜ直接書かないのか
スクリプトの中でもっとも非自明な設計がこの部分です。
# ~/.claude 配下は Claude Code が書き込み保護するため、claude には
# ステージング(cwd)へ相対パスで書かせ、後段で shell が AUTO へコピーする。
STAGING=$(mktemp -d -t skill-harvest-stg)
( cd "$STAGING" && perl -e 'alarm shift @ARGV; exec @ARGV' "$TIMEOUT_SEC" \
"$CLAUDE" --strict-mcp-config --mcp-config '{"mcpServers":{}}' -p "$PROMPT" \
--model sonnet \
--permission-mode acceptEdits \
--allowedTools "Write Edit Read" \
--max-budget-usd "$BUDGET_USD" >> "$LOG" 2>&1 < /dev/null )
Claude Codeは ~/.claude/ 配下への外部プロセス書き込みを保護します。claude -p で呼び出したサブプロセスが直接 ~/.claude/skills/auto/ にファイルを書こうとしてもブロックされます。そこで、まず /tmp/skill-harvest-stg.XXXXX/ という一時ディレクトリを作成し、そのディレクトリをcwdとして claude -p を起動します。
LLMへの指示の中に「ファイルは ./<kebab-name>/SKILL.md としてカレントディレクトリ直下に作る。絶対パス禁止」と書いてあるのはこのためです。LLMはステージングディレクトリに相対パスでファイルを書き、シェルがそれを検査してから ~/.claude/skills/auto/ にコピーします。
タイムアウトには perl -e 'alarm ...; exec ...' を使っています。zshの timeout コマンドでもよいのですが、macOSでのシグナル伝搬の挙動が微妙に違う場合があり、perlのalarmの方が確実にプロセスツリー全体を終了できるからです。設定値は TIMEOUT_SEC=600、つまり10分です。
予算上限は --max-budget-usd 1.20 です。これはClaude Maxの定額プランを使っているため実際に課金されるわけではありませんが、バッチが暴走したときの安全弁として設けています。コメントにも「Maxは定額。これは暴走防止のキャップ」と明記されています。
ファイルコピー前の検証ロジック
ステージングディレクトリのファイルを AUTO にコピーする前に、いくつかの検証が入ります。
for sd in "$STAGING"/*(/N); do
[[ -f "$sd/SKILL.md" ]] || continue
name="${sd:t}"
[[ "$name" == .* ]] && continue
# author: auto を保証(無ければ frontmatter 直後に挿入)
grep -q '^author:[[:space:]]*auto' "$sd/SKILL.md" || python3 - "$sd/SKILL.md" <<'PY'
import sys,re
p=sys.argv[1]; s=open(p).read()
if s.startswith('---'):
s=re.sub(r'^---\n', '---\nauthor: auto\n', s, count=1)
...
open(p,'w').write(s)
PY
if [[ -e "$AUTO/$name" ]]; then
echo "[$(ts)] exists, skip copy: $name" >> "$LOG"
else
cp -R "$sd" "$AUTO/$name" && { echo "[$(ts)] CREATED: $name" >> "$LOG"; ((created++)); }
fi
done
3つの検証が走ります。
1. SKILL.mdの存在確認。 ディレクトリがあってもSKILL.mdがなければスキップします。LLMが何かの拍子に空ディレクトリを作っても無視されます。
2. ドットファイル除外。 [[ "$name" == .* ]] で .archive や .snapshots などの隠しディレクトリを除外します。
3. author: auto の強制付与。 これが週次キュレーションとの連携の要です。後述する skill-curate.sh はこのフラグのあるスキルだけを整理対象にします。手動で書いたスキルに誤って触れないための安全弁として、ハーベスト経由で作られたスキルには必ず author: auto が付きます。LLMがfrontmatterを書き忘れた場合もpython3のインラインスクリプトで事後挿入します。
4. 既存スキルの上書き禁止。 [[ -e "$AUTO/$name" ]] で既存ディレクトリがあればスキップします。同名スキルの上書きは起こりません。LLMへのプロンプトでも重複は既存のpatchを促していますが、万が一のためのシェル側の二重チェックです。
skill-curate.sh:週次でかかるフィルタ
skill-harvest.sh が「作る」担当なら、skill-curate.sh は「整理する」担当です。週次で走るこのスクリプトは3つの仕事をします。
第1の仕事:実行前スナップショット。
tar czf "$SNAP/auto-$(date +%Y%m%d-%H%M%S).tar.gz" \
-C "$HOME/.claude/skills" \
--exclude='auto/.snapshots' --exclude='auto/.archive' auto 2>/dev/null
何かを変更する前に全体のtar.gzを取ります。skill-curate.sh は非破壊設計(実削除なし)ですが、移動操作のミスで元に戻せなくなる事態を防ぐためです。スナップショットは auto/.snapshots/ に蓄積されていきます。
第2の仕事:staleness判定とアーカイブ。
STALE_DAYS=30
ARCHIVE_DAYS=90
# 最終使用日: スキル名を含む会話ログの最新mtime
lastlog=$(grep -rl -- "$skill" "$LOGS" 2>/dev/null \
| while read f; do stat -f '%m' "$f" 2>/dev/null; done \
| sort -rn | head -1)
使用状況の判定に「会話ログ内でそのスキル名が出現した最新の日付」を使っているのが特徴的です。スキルファイル自体のmtimeではなく、実際に会話の中で言及されたかどうかを見ます。これにより「ファイルは存在するが誰も使っていないスキル」を確実に検出できます。
30日以上言及がなければfrontmatterの status: フィールドを stale に書き換えます。90日以上なら .archive/ ディレクトリへ物理移動します。どちらも author: auto が付いたスキルだけが対象で、手動で書いたスキルには指一本触れません。
if ! grep -q '^author:[[:space:]]*auto' "$md"; then
echo "[$(ts)] skip (not author:auto): $skill" >> "$LOG"
continue
fi
第3の仕事:LLM統合提案。
( cd "$STG" && "$CLAUDE" -p "... 重複・低品質・統合候補を洗い出してください ..." \
--model sonnet \
--permission-mode acceptEdits \
--allowedTools "Write Edit Read" \
--max-budget-usd 5.00 >> "$LOG" 2>&1 < /dev/null )
[[ -f "$STG/curator-proposals.md" ]] && cp "$STG/curator-proposals.md" "$PROP"
アクティブなスキルが2本以上あり、前回の提案ファイルより後に更新されたスキルがある場合、LLMに統合候補の分析を依頼します。予算は $5.00 と多めに設定されています。スキルが100本を超えると分析対象が増えるためです。
重要なのが、このLLM呼び出しは提案ファイルを書くだけで実際にはスキルを変更しないという点です。結果は ~/.claude/skills/auto/.curator-proposals.md に出力され、人間(私)が週に一度レビューして手動でマージや削除を判断します。完全自動でスキルを書き換えるのはリスクが高いため、最終判断は人間に留保しています。
harvest と curate の役割分担まとめ
| 観点 | skill-harvest.sh | skill-curate.sh |
|---|---|---|
| 頻度 | 毎日 午前3:30 | 週次 |
| 方向 | 作る(追加) | 整理する(stale/archive) |
| LLM予算上限 | $1.20 | $5.00 |
| 対象ログ | 前回以降の新着(最大3本) | 全アクティブスキルの最終使用日 |
| 破壊性 | 上書きなし(スキップ) | 移動のみ(実削除なし) |
| 人間の関与 | 不要 | 提案レビューのみ |
| 安全ガード | author:auto 付与を強制 | author:auto 以外を触らない |
2つのスクリプトが異なる時間軸で動くことで、短期の発見と長期の品質維持が両立しています。harvest は毎日小さく刈り取り、curate が週次で品質を保つ。このサイクルによって、スキルライブラリは「増えるが腐らない」状態を維持できます。
実装の詳細
zsh配列の分割:${(@f)...} が要る理由
スクリプトの冒頭にある配列生成のコードが、最初は意味不明に見えます。
newlogs=("${(@f)$(find "$LOGS" -name '*.md' -newer "$WM" 2>/dev/null)}")
${(@f)...} はzsh固有の展開フラグで、「改行で分割して配列要素に変換する」という意味です。$() のコマンド置換は文字列を返すだけなので、素の newlogs=($(...)) だと空白文字でも分割されてしまい、スペースを含むパス名(例:my conversation log.md)で配列が壊れます。(@f) で改行のみを分割区切りにすることで、パス名に空白があっても正しく1要素として格納されます。
次の行も同様です。
newlogs=("${(@f)$(ls -t "${newlogs[@]}" 2>/dev/null | head -$MAX_LOGS)}")
find で見つかった複数ファイルを ls -t に渡して更新日時降順に並べ、head -3 で最新3本だけ残す。一見シンプルですが、これをbashで書くと IFS=$'\n' の設定と mapfile が必要で、移植性と読みやすさが下がります。zshスクリプトとして書き直した理由のひとつが、この配列操作の簡潔さです。
タイムアウトに perl を使う理由
( cd "$STAGING" && perl -e 'alarm shift @ARGV; exec @ARGV' "$TIMEOUT_SEC" \
"$CLAUDE" ... )
timeout 600 claude ... と書けばシンプルなのに、なぜperlのalarmを使うのか。macOSの /usr/bin/timeout は、SIGALRMの伝搬方式がLinuxの timeout コマンドと微妙に異なります。特に exec で置き換えたプロセスへのシグナル伝搬が保証されないケースがあり、claude プロセスを内部から exec で呼び出す構造だと子プロセスが残留したまま親だけ死ぬことがありました。
perl -e 'alarm shift @ARGV; exec @ARGV' は「N秒後にSIGALRMを飛ばした後、残りの引数をexecで自分自身と置き換える」という動きをします。perlのalarmはプロセス自身に刺さるため、execで置き換えたclaudeプロセスに確実に届きます。ログを見ると exit 142(SIGALRMのexit code)が残るので、タイムアウト発火の検知にも使えます。
MCPを無効化する理由
"$CLAUDE" --strict-mcp-config --mcp-config '{"mcpServers":{}}' -p "$PROMPT" \
--permission-mode acceptEdits \
--allowedTools "Write Edit Read"
--strict-mcp-config --mcp-config '{"mcpServers":{}}' でMCPサーバーをすべて無効化し、--allowedTools "Write Edit Read" でLLMが使えるツールを3つだけに制限しています。
ハーベスターに必要な操作はステージングディレクトリへのファイル作成だけです。Webフェッチ・Bash・GitHub連携などのツールが使える状態でバッチを走らせると、ログ内の文脈に引きずられて「ついでにgit pushしてみる」「URLを取得してみる」といった意図しない副作用が起きるリスクがあります。最小権限の原則をバッチにも適用するのが、--allowedTools の意図です。
< /dev/null でstdinを切り離しているのも重要で、これがないとバッチが対話入力待ちでハングすることがあります。launchd経由の無人実行では標準入力がnullデバイスに接続される保証がないため、明示的に切り離します。
グロブ修飾子 /*(/N) でディレクトリだけ走査
コピー前のループはこうなっています。
for sd in "$STAGING"/*(/N); do
zshのグロブ修飾子 (/N) は「ディレクトリのみ、マッチなしでもエラーにしない」という意味です。* だとステージングにファイルが混じっていた場合にそれも処理対象になり、[[ -f "$sd/SKILL.md" ]] でスキップされるだけとはいえ無駄なループが発生します。/N を付けることで最初からディレクトリだけを返し、ループ内の条件判定を減らしています。
curate.sh の三段フォールバック
staleness判定で「最後に使われてから何日経つか」を計算する部分が、想定外にロバストな設計になっています。
lastlog, created, md = (sys.argv + ["","",""])[1:4]
ref = None
if lastlog.strip():
try: ref = float(lastlog) # ①会話ログのmtime(unix timestamp)
except: ref = None
if ref is None and created.strip():
try: ref = time.mktime(datetime.datetime.strptime(created.strip(), "%Y-%m-%d").timetuple()) # ②frontmatterのcreated
except: ref = None
if ref is None:
ref = os.path.getmtime(md) # ③SKILL.md自体のmtime
優先順位は「①会話ログへの出現→②frontmatterのcreated日→③ファイルのmtime」の順です。
①が最重要で、スキルが実際の作業で参照されていれば会話ログ内にスキル名が出現します。grep -rl -- "$skill" "$LOGS" でそのスキル名を含む会話ログファイルを全件検索し、最新のmtimeを採用します。一度も言及されていないスキルは①がNoneになり②へ落ちます。
②はスキルが作られた日付です。ハーベスターが生成したスキルには created: 2026-07-15 のようなfrontmatterがあり、それを作成日とみなします。①②どちらも取れない場合は③のファイル更新日時に落ちます。
この三段構えのおかげで、会話ログディレクトリを別マシンからマウントしていて grep が走らない状況でも、curate.sh がエラーで止まらずに動き続けます。
macOS固有の stat -f '%m'
lastlog=$(grep -rl -- "$skill" "$LOGS" 2>/dev/null \
| while read f; do stat -f '%m' "$f" 2>/dev/null; done \
| sort -rn | head -1)
stat -f '%m' はmacOS/BSD系の構文です。Linux(GNU stat)では stat -c '%Y' になります。このスクリプトはmacOS専用と割り切っているのでBSD構文をそのまま使っています。Dockerコンテナに持ち込む場合はここを書き換える必要があります。
私が詰まった話
設計は綺麗に見えますが、実際にこの構成が動くようになるまでに何度も詰まりました。症状・原因・対処の順で書きます。
詰まり①:claude コマンドが見つからず静かに終了
症状。 launchd経由でスクリプトが起動しているはずなのに、ログファイルに何も書かれない。launchdのステータスを確認すると「last exit: 0」で正常終了扱い。
原因。 スクリプト冒頭の [[ -x "$CLAUDE" ]] || { echo "claude not found" >> "$LOG"; exit 0; } のチェックが発火していたのですが、ログの書き込み先ディレクトリ自体が存在せず、>> "$LOG" もエラーで消えていました。つまりエラーのエラーが握りつぶされていた。
根本は $CLAUDE のパスが通っていないことで、原因はnvm経由のnode/claudeをPATHに含めていなかったことです。launchd経由のジョブは ~/.zshrc を読みません。plistに EnvironmentVariables として以下を追加して解決しました。
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:
/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
さらにスクリプト内でも export PATH=... を冒頭で再設定するという二重設定にしています。どちらかが欠けた場合のデバッグが地獄になるので、両方に書くのが正解です。
詰まり②:LLMが ~/.claude/ に直接書こうとしてブロックされる
症状。 ステージングパターンを導入する前の初期設計では、LLMに ~/.claude/skills/auto/<name>/SKILL.md という絶対パスを指定して書くよう指示していました。実行すると claude -p が途中で止まり、ログに Permission denied に相当するエラーが出て終了。作成件数は常に0。
原因。 Claude Codeは自分の設定ディレクトリ(~/.claude/)への外部書き込みを保護しています。claude -p で呼び出したサブプロセスが、~/.claude/ 以下のファイルをWrite toolで作成しようとするとブロックされます。
対処。 プロンプトの指示を「絶対パス禁止。カレントディレクトリ直下に ./<kebab-name>/SKILL.md を作れ」に書き換え、claude -p を起動する前に cd "$STAGING" でステージングディレクトリへ移動させる設計に変更しました。LLMはステージングに書き、シェルがそこから ~/.claude/skills/auto/ へコピーします。シェルによるコピーはゲートされません。
この制約はドキュメントに明記されているわけではなく、実際に詰まって気づいた非自明な知識です。これ自体が auto-skill claude-headless-staging-pattern として保存されています。
詰まり③:system-reminderのスキル一覧をLLMがハーベスト対象と誤認
症状。 ダイジェストの grep -v 'system-reminder' フィルタを入れる前の版では、LLMが明らかにおかしな「スキル」を生成していました。内容を見ると「このスキルは code-tour の手順です」のように、既存スキルの説明文をそのままコピーしたような中身でした。
原因。 Claude Codeの会話ログには <system-reminder> タグで囲まれた巨大なブロックが含まれており、その中に「利用可能なスキル一覧」が箇条書きで入っています。ダイジェスト生成時にこれを除去しないと、スキル一覧の説明文(「Use this skill when...」「Typical triggers include...」)がハーベスト対象として誤認されます。LLMはそれをもとに「新しい手順を発見した」と判断してファイルを作ります。
対処。 grep -v -e 'system-reminder' -e '^- [a-z0-9].*:' で二重にフィルタするようにしました。1行目は <system-reminder> タグを含む行を除去、2行目は - code-tour: や - agent-browser: Use when... のような箇条書き形式のスキル一覧行を除去します。このフィルタを入れてから、誤った「スキル」の生成頻度が大幅に下がりました。
詰まり④:LLMがファイルを書かずに返信テキストに貼り付ける
症状。 ログを見るとLLMは「以下のスキルを生成しました」と言っているのにステージングディレクトリが空。created=0 のまま終了する。
原因。 LLMに「スキルを作れ」と指示するだけでは、Write toolを使う代わりに返信テキスト中にMarkdownコードブロックでスキル内容を書いてしまうことがあります。これはLLMのデフォルトの振る舞いで、「書くべき場所がCLIの標準出力しかない」と判断したときに発生します。
対処。 プロンプトの末尾に以下を追加しました。
【最重要・厳守】
- 各スキルは必ず **Write ツール** を使って ./<kebab-name>/SKILL.md として実際にファイル作成すること
- スキル本文をこの返信メッセージに貼り付けてはいけない。必ずファイルに書き込む
- ファイルを書き終えたら、作成したスキル名だけを箇条書きで報告する(本文は不要)
「Writeツール」という具体的なツール名を明示し、「返信に貼り付けてはいけない」と逆の行動を明示的に禁止することで、ファイル生成率が安定しました。現在のスクリプトにもこの文言がそのまま入っています。
詰まり⑤:curate.sh が手書きスキルをアーカイブした
症状。 ある朝、自分で書いた重要なスキル(~/.claude/skills/auto/deploy-preflight/SKILL.md)が .archive/ に移動されていた。手書きのメモも入っていたものが消えた(正確には移動されただけですが、気づかずに「消えた」と思いました)。
原因。 skill-curate.sh の初期版には author: auto の確認ガードがなく、auto/ ディレクトリ以下の全スキルを staleness 判定の対象にしていました。私が手動で作成したスキルは当然会話ログに出現頻度が低く、90日判定を超えて .archive/ へ移動されていました。
対処。 grep -q '^author:[[:space:]]*auto' "$md" || continue の1行を追加してから、手書きスキルへの誤った操作が完全になくなりました。さらにハーベスター側にも author: auto を事後挿入するpython3インラインスクリプトを追加し、LLMがfrontmatterを書き忘れた場合も確実にタグが付くようにしました。
import sys,re
p=sys.argv[1]; s=open(p).read()
if s.startswith('---'):
s=re.sub(r'^---\n', '---\nauthor: auto\n', s, count=1)
else:
s='---\nname: %s\nauthor: auto\nversion: 1.0.0\n---\n' \
% __import__("os").path.basename(__import__("os").path.dirname(p)) + s
open(p,'w').write(s)
フロントマターがある場合は ---\n の直後に挿入、フロントマターが完全にない場合は最低限の構造を丸ごと前置する二段構えになっています。インラインpythonをシェルスクリプト内に埋め込むのは見た目がやや奇妙ですが、python3スクリプトファイルを別途管理するよりも自己完結していて、スクリプト単体をどこに置いても動きます。
詰まり⑥:ウォーターマーク更新のタイミング
これは詰まりというより設計判断の話ですが、間違えると痛い箇所です。当初はLLM呼び出しが成功した場合だけ touch "$WM" するようにしていました。タイムアウトやLLMエラーのときはウォーターマークを更新せず、次回実行時にリトライさせる意図でした。
しかし実際に運用すると、「ハーベストに値するものが何もなかった会話ログ」が毎晩リトライされ続けるという問題が起きました。$1.20 の予算上限を設けているので青天井にはなりませんが、空振りのLLM呼び出しが毎日走るのはMacBookのバッテリーと環境変数的な意味でノイズです。
現在のコードでは rc=$?(claudeのexit code)に関わらず、ループ最終行で無条件に touch "$WM" します。「会話ログを読んだ」という事実だけを記録し、「スキルが生まれたかどうか」は問わない。やることをやったらウォーターマークを刻む。これが正しい設計でした。
つまずきポイント
前段では6つの詰まり(PATHが通らない・~/.claude/直接書きブロック・system-reminder誤認・テキスト貼付・手書きスキルのアーカイブ誤爆・ウォーターマークのタイミング)を詳しく解説しました。ここではそれ以外の「やってみないとわからない」つまずきを、実コードを根拠にして列挙します。
-
launchdのログとスクリプトのログが別ファイル。 plistには
StandardErrorPathとして~/.claude/logs/com.shun.skill-harvest.logが指定されています。スクリプト本体が書くログはLOG="$AUTO/.harvest.log"つまり~/.claude/skills/auto/.harvest.logです。起動失敗(zsh自体が起動できないケースなど)はplist側のログにしか残りません。harvest.logには何も書かれない。デバッグ時は必ず2箇所確認が必要です。私は最初の2週間これに気づかず、launchd側のエラーを見落とし続けました。 -
curate.sh の
nollmオプションを知らないと修正のたびに10分待つ。 スクリプトの第1引数にnollmを渡すとLLM呼び出しをスキップできます(RUN_LLM="${1:-llm}")。staleness判定・アーカイブ移動・スナップショットのロジックだけ確認したいときに使います。これを知らないと修正→テスト→修正のサイクルのたびに$5.00上限のLLM呼び出しが走ります。 -
スナップショットが溜まりすぎる。 週次でtar.gzが生成されます。スキルが200本に育つと1スナップショットが数MBになり、1年で50本以上が
auto/.snapshots/に蓄積します。現状のスクリプトに自動削除のロジックはありません。定期的に手動で消すか、curate.sh 末尾にfind "$SNAP" -name '*.tar.gz' -mtime +180 -deleteの1行を追記するのが現実的です。 -
--add-dirを忘れると curate.sh のLLMがAUTO配下を読めない。 curate.sh では--add-dir "$AUTO"でAUTO配下をLLMの読み取り可能ディレクトリに追加しています。harvest.sh はcd "$STAGING"でステージングをcwdにすることで暗黙的に解決していますが、curate.sh では作業ディレクトリが/tmpの一時ディレクトリです。--add-dirを省くとRead toolでSKILL.mdにアクセスしようとしても「アクセス拒否」で止まります。 -
active >= 2の条件でLLM提案がスキップされる。 curate.sh の条件分岐は(( active >= 2 ))です。アクティブスキルが1本以下だとLLM統合提案が走りません。セットアップ直後に手動で curate.sh を実行しても.curator-proposals.mdが生成されないのはこのためです。 -
スキル名のケバブケースが不統一で重複スキルが増える。 harvest.sh がLLMに渡す既存スキル一覧はディレクトリ名のカンマ区切りのみです(
existing=$(ls "$AUTO" 2>/dev/null | grep -v '^\.' | tr '\n' ','))。内容は渡していないので、launchd-path-setupとlaunchd-env-varsのように名前が似ていても異なれば別スキルとして作られます。内容レベルの重複は週次curate.shのLLM提案でしか検出できないため、提案ファイルを定期的にレビューしてマージする必要があります。 -
grep -rl -- "$skill"の部分一致でstaleness判定が狂う。 curate.sh はスキル名を含む会話ログの最新mtimeを最終使用日とみなします。スキル名がlog、api、testのような汎用語だと会話ログのあらゆる出現にマッチして「常に使われている」と誤判定されます。スキル名はできるだけ固有で具体的にする(claude-headless-staging-pattern、launchd-nvm-path-workaround)ことが、staleness判定の精度を保つ前提条件です。 -
PER_LOG_BYTES=15000 で後半の発見を取りこぼす。 1ログあたり15,000バイトでカットします(
grep ... "$f" | head -c $PER_LOG_BYTES)。長い会話では後半に書かれた最終的な解決策がハーベスト対象から外れます。対話の前半に課題設定・後半に解決というパターンが多いので、解決策だけ取りこぼすケースが実際に発生しました。セッションを短めに分割して保存するか、MAX_LOGSとPER_LOG_BYTESを環境に合わせて調整してください。 -
nvmアップグレード後にplistのPATHが古いバージョンを指す。 plistの
EnvironmentVariablesにはv24.13.0のようにnodeのバージョン番号が含まれます。nvmでnodeを更新するたびにplistの修正とlaunchctl unload→launchctl loadによる再読み込みが必要です。これを忘れると次の3:30amにバッチがサイレント失敗します。nodeアップグレードのチェックリストにこの手順を組み込んでおくことを強くすすめます。 -
--permission-mode acceptEditsを省くとWriteが止まる。-p(headlessモード)でもpermission-modeを省くとWrite toolの実行前に確認プロンプトが出ようとし、stdinが/dev/nullのまま無限待機になります。launchd経由では10分後にタイムアウトで強制終了されます。harvest.sh も curate.sh も--permission-mode acceptEditsを明示しており、これは-pと常にセットで書く必須オプションです。
ベストプラクティス
実際に半年以上運用して定着したルールを、実コードと合わせて整理します。
① plistとスクリプトの両方にPATHを書く
# スクリプト冒頭
export PATH="$HOME/.local/bin:$HOME/.nvm/versions/node/v24.13.0/bin:/usr/bin:/bin:/usr/sbin:/sbin"
plistの EnvironmentVariables にも同じPATHを書きます。冗長に見えますが、launchd経由・直接呼び出しのどちらでも動作を保証するための二重設定です。片方だけだと「ターミナルから手動実行すると動くのにlaunchdから動かない」という症状になります。どちらの経路でも確実に動かす設計が、定期バッチの鉄則です。
② ~/.claude/ 以下への書き込みは常にステージングパターンで
STAGING=$(mktemp -d -t skill-harvest-stg)
( cd "$STAGING" && "$CLAUDE" -p "$PROMPT" --permission-mode acceptEdits ... )
cp -R "$sd" "$AUTO/$name" # shellがコピー
「LLMはステージングに書く、シェルがコピーする」という役割分担を原則にします。他の自動化でも ~/.claude/ 配下にLLMにファイルを作らせる場合はこのパターンを流用してください。
③ author: auto タグは2段階で保証する
LLMがfrontmatterを書き忘れることがあります。harvest.sh ではシェル側でも事後確認を入れています。
grep -q '^author:[[:space:]]*auto' "$sd/SKILL.md" || python3 - "$sd/SKILL.md" <<'PY'
import sys, re
p = sys.argv[1]; s = open(p).read()
if s.startswith('---'):
s = re.sub(r'^---\n', '---\nauthor: auto\n', s, count=1)
else:
s = '---\nname: %s\nauthor: auto\nversion: 1.0.0\n---\n' \
% __import__("os").path.basename(__import__("os").path.dirname(p)) + s
open(p, 'w').write(s)
PY
このタグがないと curate.sh が手動スキルと誤認して触れなくなります。プロンプトで指示するだけでなく、シェルが「なければ強制付与」するフォールバックを持つ設計が安全です。
④ ウォーターマークは成否に関わらず更新する
# スクリプト末尾
touch "$WM"
exit 0
LLMのexit codeに関わらず touch "$WM" します。「ハーベストする価値がなかった会話ログ」を毎晩リトライし続けるコストを避けるためです。「会話ログを読んだ」という事実だけを記録し、「スキルが生まれたか」は問わない。これが正しい設計でした。
⑤ 既存スキル一覧をLLMに渡して重複判断をLLM側に委ねる
existing=$(ls "$AUTO" 2>/dev/null | grep -v '^\.' | tr '\n' ',')
# プロンプトに渡す
# 既存の auto スキル(重複作成は禁止。重複するなら新規作成せず既存を patch):
# ${existing:-(なし)}
名前が完全一致しない「実質的な重複」をシェル側のロジックで検出するのは難しい。LLMに一覧を見せて「これと似ているなら作らない」と判断させる方が実用的です。
⑥ stdin を /dev/null で切り離す
"$CLAUDE" ... >> "$LOG" 2>&1 < /dev/null
launchd経由ではstdinがnullデバイスに接続される保証がありません。これを省くとLLMが入力待ちでハングします。--permission-mode acceptEdits と並んで、headlessバッチ実行の必須オプションです。harvest.sh も curate.sh も両方に付いています。
⑦ --allowedTools で使えるツールを3つに絞る
--allowedTools "Write Edit Read"
harvesterに必要な操作はステージングへのファイル作成だけです。Bash・WebFetch・MCPを使える状態でバッチを走らせると、会話ログの文脈に引きずられて意図しない副作用(git push、URL取得)が起きるリスクがあります。最小権限の原則はバッチにも適用します。
⑧ SKILL.md の description には発火条件を具体的に書く
harvest.sh のプロンプトには description(いつ発火すべきか) という記述があります。
Claude Codeはこのフィールドを見て「このタスクに関係するスキルを読む」と判断します。「launchdのplistを作成するとき」「nvmのPATH問題に遭遇したとき」のように発火条件が具体的であるほど、Claude Codeが適切なタイミングで自動参照します。汎用的な説明文では参照されません。
⑨ curate.sh は非破壊設計を守る
# 実削除なし。移動のみ。
mv "$d" "$ARCH/"
実削除は行わず .archive/ への移動にとどめます。staleness判定が誤判定だった場合でも auto/.archive/ から元の場所に戻せます。さらに変更前のスナップショットがあるので二重の安全網があります。自動化スクリプトで削除を行うのは「取り返しがつかない」操作です。判断に迷う場合は必ず「移動」を選びます。
⑩ テスト実行は nollm オプションで素早く済ませる
skill-curate.sh nollm
スクリプトの修正後は nollm でLLMをスキップしてstaleness判定とアーカイブだけ確認します。LLM呼び出しを伴うフルテストは月に1〜2回で十分です。修正サイクルのたびに10分待つのをやめるだけで、スクリプト改善の心理的コストが大幅に下がります。
⑪ スキル名はできるだけ固有で長めのケバブケースにする
curate.sh のstaleness判定は grep -rl -- "$skill" でスキル名を検索します。スキル名が短い汎用語だとノイズが多い。launchd-nvm-path-workaround、claude-headless-staging-pattern のような固有で具体的な名前にすることで、staleness判定の精度と重複排除の効果が格段に上がります。
⑫ MCP は完全無効化する
--strict-mcp-config --mcp-config '{"mcpServers":{}}'
harvest.sh も curate.sh も、MCP接続を空のJSONで明示的に無効化しています。バッチ実行中にMCP経由で外部サービスへ接続されるリスクを完全に断ちます。最小権限の原則の徹底です。
⑬ スナップショットに保存期限をつける
curate.sh 末尾に1行追記するだけです。
find "$SNAP" -name '*.tar.gz' -mtime +180 -delete
これで180日(約半年)以上前のスナップショットが自動削除されます。現状のスクリプトにはこの行がないので、長期運用するなら早めに追加することをすすめます。
まとめ
skill-harvest.sh と skill-curate.sh が連携するこの設計を振り返ると、3つの原則に集約されます。
セッションをまたぐ知識は自動で外部ファイルに落とす。 Claude Codeのコンテキストは毎セッション消えます。会話ログから手順的知識を自動で拾い上げてSKILL.mdとして保存する設計は、モデルの記憶制限を外部ファイルシステムで補う最もシンプルな方法です。人間が「メモしよう」と思った瞬間に保存は失敗します。保存は自動でなければ続きません。
作る仕組みと整理する仕組みは時間軸を分ける。 毎日のharvestは「増やす」だけ、週次のcurateは「整理する」だけという役割分担が効いています。harvest のLLM予算は $1.20、curate は $5.00 と用途に応じて設定が違います。同じスクリプトにこれらを詰め込むと、どちらの品質も落ちます。異なる頻度・異なる予算・異なる破壊性のタスクは分離することが原則です。
人間の関与は最終判断にだけ留保する。 curate.sh のLLM呼び出しは .curator-proposals.md を書くだけで実変更はしません。完全自動化は「気づかない間に重要なスキルが消えた」リスクを生みます。週1回の提案レビューという薄い人間の関与を残すことで、自動化の速度と安全性を両立しています。
スキルライブラリが200本を超えた今、朝のセッション開始時点ですでに昨日の学びが参照可能な状態にあります。「前も調べたな」という感覚がほぼなくなりました。1日の作業量を増やすより、翌週の自分が今週より賢い状態でスタートできる環境を作る方が、長期的なアウトプットをずっと大きく変えます。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!