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?

使われなくなったスキルを自動で墓場送り ― launchd週次のスキルライフサイクル管理

0
Posted at

前回は 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 -vsystem-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サービスを量産しています

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

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?