前回は Claude Codeのモデル終了を自動検知してエイリアスを切り替える話 を書きました。今回も同じ launchd の仕組みを使って、膨れ上がる auto-skill を腐らせないライフサイクル管理を実装した話です。
以前の記事で書いた通り、auto-skill は会話ログから夜間バッチが自動生成します。放置すると ~/.claude/skills/auto/ が際限なく肥大し、Claude Code 起動時のコンテキストロードが重くなる。かといって手動で間引くのは続きません。今回は「最終使用日の自動算出 → 2段階退避」で、これを完全に自動化しました。
困りごと:生成は自動なのに廃棄は手動
夜間バッチ skill-harvest.sh は毎日 3:30 に走ってスキルを追加し続けます。一方、「このスキルはもう使われていない」を知る仕組みが何もない。気づくと以下の状態になっています。
- どれが現役でどれが死んでいるか誰も分からない
- 全スキルがコンテキストにロードされ起動が重くなる
- 削除するのが怖くて手動整理が後回しになる
「削除はしたくないが、腐ったものは視野から外したい」という要件を満たす非破壊の2段退避が必要でした。
全体設計:harvest(日次)と curate(週次)を分離する
毎日 3:30 skill-harvest.sh 会話ログ → 新スキル生成
毎週日曜 4:15 skill-curate.sh スキル名を会話ログで逆引き → last-used 算出 → 退避
生成と整理を別プロセスに切り離すのがポイントです。harvest が足し算、curate が引き算(実際には「墓場への移送」)。harvest の 45 分後に curate が走る設計にしているのは、生まれたての status: active スキルを当日の curate に評価させないためです。
harvest:last-used の原票を作る
skill-curate.sh が「このスキルをいつ最後に使ったか」を判断するには根拠が必要です。その根拠が「スキル名が会話ログに登場した最終 mtime」です。
harvest 側で会話ログを ~/Documents/my-knowledge-base/raw/conversations/*.md に蓄積し、curate がそこを grep します。
harvest のパラメータは以下の通りです(skill-harvest.sh より)。
MAX_LOGS=3 # 1回で扱うログ本数
PER_LOG_BYTES=15000 # ログ1本あたりの取り込み上限バイト
BUDGET_USD=1.20 # 暴走防止のコストキャップ
TIMEOUT_SEC=600
ログは grep -v で system-reminder 行とスキル一覧行のノイズを除外してからバイト上限で切り、claude -p に渡しています。
grep -v -e 'system-reminder' -e '^- [a-z0-9].*:' "$f" 2>/dev/null | head -c $PER_LOG_BYTES
curate:last-used の算出ロジック
curate の核は、スキル名をキーに会話ログを grep して mtime を逆引きする部分です(skill-curate.sh より)。
lastlog=$(grep -rl -- "$skill" "$LOGS" 2>/dev/null \
| while read f; do stat -f '%m' "$f" 2>/dev/null; done \
| sort -rn | head -1)
grep -rl でスキル名を含むファイルを列挙し、stat -f '%m' で各ファイルの mtime(Unix タイムスタンプ)を取り出して最大値を選びます。
この値を Python3 ワンライナーで経過日数に変換します。
import sys, os, time, datetime
lastlog, created, md = (sys.argv + ["","",""])[1:4]
ref = None
if lastlog.strip():
try: ref = float(lastlog)
except: ref = None
if ref is None and created.strip():
try: ref = time.mktime(datetime.datetime.strptime(created.strip(), "%Y-%m-%d").timetuple())
except: ref = None
if ref is None:
ref = os.path.getmtime(md)
print(int((time.time() - ref) // 86400))
フォールバックが 3 段あります。①会話ログの mtime → ②frontmatter の created: → ③ SKILL.md ファイル自体の mtime。ログに一度も登場していない新スキルでも、created 日からカウントが始まります。
2段退避:30日 → stale / 90日 → .archive
経過日数が出たら以下の条件分岐で処理します。
if (( days > ARCHIVE_DAYS )); then # 90日超
mv "$d" "$ARCH/" && echo "[$(ts)] ARCHIVED (${days}d unused): $skill" >> "$LOG"
elif (( days > STALE_DAYS )); then # 30日超
python3 - "$md" <<'PY'
import sys, re
p = sys.argv[1]; s = open(p).read()
if re.search(r'^status:', s, re.M):
s = re.sub(r'^status:.*$', 'status: stale', s, count=1, flags=re.M)
else:
s = re.sub(r'^(author:[ \t]*auto.*)$', r'\1\nstatus: stale', s, count=1, flags=re.M)
open(p, 'w').write(s)
PY
echo "[$(ts)] stale (${days}d unused): $skill" >> "$LOG"
((active++))
else
((active++))
fi
-
30日超:frontmatter の
status:をstaleに書き換える。ファイルは動かさない -
90日超:
~/.claude/skills/auto/.archive/に物理移動する。実削除はしない
status: stale は将来的にスキルローダー側でフィルタする際の目印です。stale を除外すればコンテキストロードを軽くできます。
「30日触っていない」はスキルが悪いとは限らない。その作業をしていないだけかもしれない。だから最終判断は人間に残し、curate は降格と移送のみを担当します。
非破壊の安全装置
実行前スナップショット
curate が何をする前に必ずここを通ります。
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 \
&& echo "[$(ts)] snapshot taken" >> "$LOG"
~/.claude/skills/auto/.snapshots/auto-YYYYMMDD-HHMMSS.tar.gz にスナップを取ってから処理を開始します。間違えて退避されても tar xzf で即復元できます。
author: auto 以外は絶対に触れない
if ! grep -q '^author:[[:space:]]*auto' "$md"; then
echo "[$(ts)] skip (not author:auto): $skill" >> "$LOG"
continue
fi
手書きスキルやバンドルスキルは author: auto を持たないため完全にスキップされます。
LLM 統合提案フェーズ
日数による機械的な退避に加えて、意味的に重複しているスキルを LLM に洗い出させるフェーズがあります。
if [[ "$RUN_LLM" != "nollm" ]] && (( active >= 2 )) && [[ -x "$CLAUDE" ]]; then
# 前回提案ファイルより後に更新されたSKILL.mdだけを対象にする
...
STG=$(mktemp -d -t skill-curate-stg)
( cd "$STG" && perl -e 'alarm 600; exec @ARGV' "$CLAUDE" -p "..." \
--model sonnet \
--permission-mode acceptEdits \
--allowedTools "Write Edit Read" \
--add-dir "$AUTO" \
--max-budget-usd 5.00 >> "$LOG" 2>&1 < /dev/null )
[[ -f "$STG/curator-proposals.md" ]] \
&& cp "$STG/curator-proposals.md" "$PROP" \
&& echo "[$(ts)] proposals written -> $PROP" >> "$LOG"
rm -rf "$STG"
fi
設計上の工夫が 2 つあります。
差分だけ渡す:前回の .curator-proposals.md より後に更新された SKILL.md だけを対象にします。毎週全スキルを LLM に読ませるとコストが積み上がるため、変更のあったスキルのみを処理します。
提案は書かせるが適用はしない:claude に ./curator-proposals.md を Write させ、shell が ~/.claude/skills/auto/.curator-proposals.md にコピーして終わりです。スキルの実ファイルには一切触れません。レビューと適用は人間の仕事です。
第 1 引数 nollm を渡すと LLM フェーズを丸ごとスキップできます。テスト時に役立ちます。
skill-curate.sh nollm
launchd 登録
~/Library/LaunchAgents/com.shun.skill-harvest.plist(スケジュール部分)
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>30</integer>
</dict>
~/Library/LaunchAgents/com.shun.skill-curate.plist(スケジュール部分)
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>4</integer>
<key>Minute</key><integer>15</integer>
<key>Weekday</key><integer>0</integer>
</dict>
Weekday: 0 が日曜日です。両 plist とも ProcessType: Background で動き、ログはそれぞれ ~/.claude/logs/com.shun.skill-harvest.log と ~/.claude/logs/com.shun.skill-curate.log に分かれて吐かれます。
登録と即時テストは以下で行います。
launchctl load ~/Library/LaunchAgents/com.shun.skill-harvest.plist
launchctl load ~/Library/LaunchAgents/com.shun.skill-curate.plist
# 今すぐ走らせて動作確認
launchctl start com.shun.skill-harvest
launchctl start com.shun.skill-curate
踏んだ落とし穴
-
grep -rlが全ログを毎回走査する:watermark は harvest 側のためのもので curate がスキル名を軸に引く構造上は使えない。ログ本数が増えると遅くなる。現状は許容範囲内だが、将来はインデックスを作る必要がある -
stat -f '%m'は macOS 専用:Linux ではstat --format='%Y'。このスクリプトは macOS の zsh 前提で書かれている -
launchd の PATH が極小:素の launchd 環境では
claudeコマンドが見つからない。plist のEnvironmentVariables/PATHに nvm の bin ディレクトリと~/.local/binを明示的に追加しないと即 exit する -
~/.claudeへの直書きが permission denied になる:Claude Code が起動中は~/.claude配下への書き込みをブロックすることがある。harvest も curate もmktempでステージングディレクトリを作り、shell がコピーする構造になっている。直接~/.claude/skills/auto/に書かせると落ちる -
LLM に全スキルを渡すとコストが跳ねる:
--add-dir "$AUTO"で読める状態にしつつ、プロンプト側で「前回提案ファイルより後に更新された SKILL.md だけを Read して」と指示している。指示がないと LLM が全件読みに行く
まとめ
- auto-skill のライフサイクルは 30日 →
status: stale/ 90日 →.archive/移送の 2 段で管理する - 「最終使用日」は会話ログへのスキル名登場 mtime を
grep -rlで逆引きして算出する -
実行前 tar スナップショット +
author: auto以外は触れない、で非破壊を保証する - LLM 統合提案は差分だけ渡して
.curator-proposals.mdに書かせる。スキル実ファイルの適用は人間が行う - harvest(日次 3:30)と curate(週次日曜 4:15)を launchd に分離して登録する
次回は、スキル・メモリ・会話ログが積み上がった後に Claude Code 自体が重くなった話 ―― コンテキスト注入量の監査と削減を書く予定です。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!