🗑 積みすぎたAuto-skillを週次で整理する自動キュレーション
Claude Codeは使えば使うほど賢くなる、というのは半分本当で、半分嘘です。
半年前、大学生のころに月10万だった収入が、副業を掛け持ちしながら60万まで増え、会社都合で解雇されてからの半年でClaude Code自律環境を構築し、今は月商120万で回っています。その過程で痛感したのが「スキルの腐敗」です。最初は手になじんだ道具が、3ヶ月後には誰も使わない文鎮になっている。そしてその文鎮は毎回の会話で読み込まれ続け、コンテキストを静かに圧迫します。今回は、その問題を週次の自動キュレーションで解決した話をします。
なぜこの仕組みが効くのか
「使うほど賢くなる」の裏側にある落とし穴
Claude Codeには auto-skill と呼ぶ仕組みがあります。非自明なタスクを5回以上こなしたとき、回避策を発見したとき、アプローチを修正されたとき、AIが自律的に ~/.claude/skills/auto/<kebab-name>/SKILL.md へ手順を書き出す設計です。
このメモが積み重なるほど、次の会話でその経験を参照できる。環境の「記憶」が育っていく感覚は、たしかに気持ちいいものです。
しかし問題がある。
Claude Codeのコンテキスト注入は、スキルファイルが増えれば増えるほど読み込み量が増えます。スキルを30個、50個と溜め込んでいくと、会話の冒頭だけで数千トークンが「使われていないスキルの説明」に費やされます。Opusやフル品質のSonnetでは、コンテキスト汚染は出力品質の劣化に直結する。毎月課金されるトークン代は増え、応答の鋭さは落ちる。まるでデスクに使わない書類を積み上げ続けるようなものです。
Lily自身の環境で実際に確認した構造を言うと、~/.claude/skills/auto/ 配下に各スキルがディレクトリとして並び、その中の SKILL.md が author: auto というフロントマターを持つ。この author: auto フィールドがない手動スキルには、今回の整理機構は一切触れません。誤爆を防ぐ安全ガードが、設計の最初に置かれている点が重要です。
「作業」ではなく「環境」に投資する意味
月商120万の仕事の大半は、Claudeに仕事を流す設計を作ることです。個別タスクをこなすより、次の100タスクが自動化される仕組みを作るほうが価値が高い。これはどの個人開発者にも当てはまる原則だと思います。
しかしその「環境」自体が腐敗すると、整備コストが逆に重くなる。auto-skillのキュレーション自動化は、まさにこの「環境の環境を整備する」メタレイヤーです。読者の方も、Claude Codeを使い込むほど「前に作ったはずのスキル、どこだっけ」「このスキル、まだ使ってる?」という感覚が出てくるはずです。その感覚を放置せずに自動化した、という話です。
コンテキスト肥大化の具体的な経路
スキルが増えると何が起きるか、具体的に追います。
~/.claude/CLAUDE.md には スキル自己生成(auto-skills) というセクションがあり、「再利用価値のある手順は頼まれなくても自作する」 と書いています。この指示が生きている限り、スキルは自然増殖します。
問題は「使われなくなった」スキルの検知が、人間の目視に頼っていること。手作業でスキルディレクトリを眺めても、どれが現役でどれが遺物かは一目でわかりません。かといって全部消すのは怖い。このジレンマを解消するのが今回の週次キュレーターです。
設計の核心は3点です。
- 会話ログへのスキル名言及を「使用の代理指標」にする → 直接のAPI呼び出しログではなく、
~/Documents/my-knowledge-base/raw/conversations/内の会話ログファイルからスキル名をgrepし、最新のファイルmtimeを「最終使用日」とみなす。 - 30日未使用でstale・90日未使用で
.archive/退避 → どちらも実削除ではない。staleはフロントマターにstatus: staleを追記するだけ、archiveはmvで別ディレクトリへ。 - 実行前に必ずスナップショットを取る → 操作を間違えてもワンコマンドで元に戻せる。
全体の流れ
アーキテクチャ概観
[毎週日曜 4:15 AM]
↓
com.shun.skill-curate (launchd)
↓
skill-curate.sh
│
├─ ① スナップショット取得
│ ~/.claude/skills/auto/.snapshots/
│ auto-YYYYMMDD-HHMMSS.tar.gz
│
├─ ② auto/配下を全スキルスキャン
│ author: auto でないものはスキップ
│
├─ ③ 最終使用日の算出
│ 会話ログ grep → mtime
│ → created: フロントマター
│ → SKILL.md のファイルmtime
│
├─ ④ 日数判定
│ > 90日 → .archive/ へ mv(非破壊)
│ > 30日 → status: stale を追記
│ それ以外 → active カウント++
│
└─ ⑤ LLM統合提案(オプション)
active ≥ 2 のとき Claude を呼び出し
重複・低品質候補を .curator-proposals.md に書き出し
実スキルは変更しない(提案のみ)
launchdの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 が日曜、Hour: 4・Minute: 15 で毎週日曜 4:15 AM に起動します。LowPriorityIO: true・Nice: 10 で最低優先度のバックグラウンド実行です。眠っている間に片付く。人間のコストはゼロです。
① スナップショット:完全リバーサブルの基盤
スクリプトの冒頭でまずスナップショットを取ります。
AUTO="$HOME/.claude/skills/auto"
SNAP="$AUTO/.snapshots"
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"
.snapshots/ と .archive/ 自身は除外して圧縮しています。これをやらないと、アーカイブの中にアーカイブが入り込む再帰問題が起きる。date +%Y%m%d-%H%M%S でタイムスタンプ付きの名前にしているので、週を追うごとにスナップショットが積み上がります。
リストア方法は単純で、tar xzf ~/.claude/skills/auto/.snapshots/auto-20260803-041500.tar.gz -C ~/.claude/skills/ を実行するだけです。週次でスナップショットが増えていく点は別途 find で古いものを消す運用が必要ですが、それは次の課題です。
② 安全ガード:author: auto 以外は絶対に触らない
if ! grep -q '^author:[[:space:]]*auto' "$md"; then
echo "[$(ts)] skip (not author:auto): $skill" >> "$LOG"
continue
fi
これがあることで、手動で丁寧に育てたスキルが誤って退避されることはありません。author: auto というフロントマーカーが「機械が生成した整理対象」を示す唯一の旗です。逆に言えば、残したいスキルは author: フィールドを auto 以外に書き換えるだけで保護できます。シンプルで強い。
③ 最終使用日の算出:代理指標の設計と限界
ここが設計の中で最も工夫が要る部分です。Claude Codeには「どのスキルが呼ばれた」というAPIレベルのログが外部から取れません。そのため、会話ログのテキストにスキル名が出現したかどうか を代理指標として使っています。
lastlog=$(grep -rl -- "$skill" "$LOGS" 2>/dev/null \
| while read f; do stat -f '%m' "$f" 2>/dev/null; done \
| sort -rn | head -1)
LOGS="$HOME/Documents/my-knowledge-base/raw/conversations/" を全文検索し、スキル名を含むファイルのうち最も新しいものの mtime をUnixタイムスタンプで取得します。
取得できなかった場合、フォールバックが2段階あります。
# Pythonインラインスクリプトより(skill-curate.sh 42-56行目)
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))
① 会話ログのmtime → ② SKILL.md の created: フロントマター → ③ SKILL.md ファイル自体のmtime、という順です。created: が書かれていない古いスキルでも、ファイルmtimeという最後の砦がある。
ただし、この代理指標には構造的な限界があります。
「会話ログにスキル名が登場する」と「スキルが実際に呼び出されて機能した」は、厳密には別の事象です。スキルの名前が会話の中で言及された(例: 「このスキルは不要かも」と議論した)場合でも、使用とカウントされます。逆に、スキル名がコマンド形式(/curate-skills など)として呼ばれていてもログの検索パターンが一致しない場合は拾えません。
この制約を知った上で「それでも会話ログが最良の近似指標」という判断を取っています。完璧な使用ログより、運用可能な近似指標を選ぶ。これが実用的な自動化の哲学です。
④ 判定と処理:staleとarchiveの二段階
STALE_DAYS=30
ARCHIVE_DAYS=90
if (( days > ARCHIVE_DAYS )); then
mv "$d" "$ARCH/" && echo "[$(ts)] ARCHIVED (${days}d unused): $skill" >> "$LOG"
elif (( days > STALE_DAYS )); then
# SKILL.md の status フィールドを stale に書き換える
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日未使用のスキルは status: stale をフロントマターに書き込みます。実際の削除でも退避でもなく、フラグを立てるだけです。このフラグがあることで、スキルインデックスが status: stale を表示したり、次の整理フェーズで優先して検討したりできます。
90日未使用のスキルは .archive/ へ mv します。rm ではなく mv なので、ファイルシステム上では消えていません。ls ~/.claude/skills/auto/.archive/ で確認でき、必要なら mv で戻せます。この非破壊性が重要で、「消した後に必要だとわかった」というシナリオを完全にカバーします。
stale判定されたスキルも active++ のカウントに含まれる点に注目してください。staleはあくまで「要注意フラグ」であり、スキル自体はまだ auto/ 配下に存在しています。次の週次実行で30日がさらに経過すれば、stale→archiveへ自然に昇格します。
⑤ LLM統合提案:Claudeがスキルを読んで提案を書く
if [[ "$RUN_LLM" != "nollm" ]] && (( active >= 2 )) && [[ -x "$CLAUDE" ]]; then
activeスキルが2件以上あり、かつ nollm 引数が渡されていない場合に、Claudeを呼び出して重複・低品質スキルの統合提案を生成します。
STG=$(mktemp -d -t skill-curate-stg)
( cd "$STG" && perl -e 'alarm 600; exec @ARGV' "$CLAUDE" \
--strict-mcp-config \
--mcp-config '{"mcpServers":{}}' \
-p "${AUTO} 配下の自動生成スキルのうち、前回提案ファイル ${PROP} より後に更新された
SKILL.md のみを Read し、重複・低品質・統合候補を洗い出してください。..." \
--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"
設計上の工夫がいくつかあります。
--mcp-config '{"mcpServers":{}}' でMCPを無効化しています。 週次バッチの中でネットワーク依存のMCPサーバーが突然起動するのは不安定要因です。スキルの読み込みと提案書き出しだけに絞る。
--max-budget-usd 5.00 でコストに上限を設けています。 Claude APIの課金が無制限に走らないよう、1回の提案生成は5ドルまで。提案ファイルが消えても困らないレベルのタスクに、それ以上の課金をする必要はありません。
perl -e 'alarm 600; exec @ARGV' でタイムアウトを600秒に設定しています。 単純な timeout コマンドはシグナルの伝播方法によって、Claudeプロセスが正常に終了しない場合があります。Perl の alarm を使った exec は、サブプロセスも含めて確実に終了させます。
Claudeにはステージングディレクトリ($STG)へ curator-proposals.md を書かせ、シェルがコピーする構造にしています。 ~/.claude/ は書き込み保護されているケースがあるため、Claudeに直接書かせるとエラーになる可能性があります。一時ディレクトリを中継点にすることで、権限問題を回避しています。
前回の提案ファイルより新しいSKILL.mdだけを対象にしています。 毎週全スキルを読み直すのはトークンの無駄です。prop_mtime (前回の提案ファイルのmtime)より新しい SKILL.md だけを差分処理することで、週次実行のコストを最小化しています。
全体コードの変数マップ
実コードを読む際の参照用として、スクリプト内の主要変数を整理しておきます。
| 変数 | 値(実コードより) | 役割 |
|---|---|---|
AUTO | ~/.claude/skills/auto | スキル格納ルート |
LOGS | ~/Documents/my-knowledge-base/raw/conversations | 会話ログ検索対象 |
SNAP | ~/.claude/skills/auto/.snapshots | スナップショット保存先 |
ARCH | ~/.claude/skills/auto/.archive | archive退避先 |
LOG | ~/.claude/skills/auto/.curate.log | 実行ログ |
PROP | ~/.claude/skills/auto/.curator-proposals.md | LLM提案出力先 |
STALE_DAYS | 30 | staleフラグ閾値(日数) |
ARCHIVE_DAYS | 90 | archive退避閾値(日数) |
RUN_LLM | 第1引数、デフォルト "llm" | "nollm" でLLMフェーズスキップ |
launchdの実行ログは ~/.claude/logs/com.shun.skill-curate.log に書き込まれます(plistの StandardOutPath / StandardErrorPath)。スクリプト内のアプリログ($LOG)とは別ファイルで、launchd起動時のプロセスレベルのエラーはこちらに残ります。
この二重ログ構造は、「スクリプトが起動したかどうか」と「スクリプトが何をしたか」を分けて追跡できる点で有効です。launchdのログに何も書かれていなければスクリプトが起動していない。スクリプトのログが snapshot taken で止まっていれば、その先でエラーが起きています。
ここまでが「なぜこの仕組みが効くのか」と「どう動いているのか」の全体像です。次は、この設計の最大の弱点である 「会話ログ言及を使用と見なす」代理指標の検証方法と、実際に踏んだつまずき を掘り下げます。
実装の詳細
冒頭2行が黙って守っているもの
set -u
export PATH="$HOME/.local/bin:$HOME/.nvm/versions/node/v24.13.0/bin:/usr/bin:/bin:/usr/sbin:/sbin"
set -u は未定義変数を参照した瞬間にスクリプトを止めます。一見地味ですが、これがないと "$lastlog" が空のまま float() に渡り、Pythonが黙って 0 を返し、全スキルが「最終使用:1970年」扱いになります。一晩で全スキルがarchiveされ、朝起きたらスキルが消えていた、という事故を防ぐ一行です。
export PATH はもっと即物的な理由です。launchdが起動するシェルは、あなたが毎日使うzshとは別の最小環境です。~/.zshrc も ~/.nvm/nvm.sh も読み込まれない。つまり claude コマンドも node も、PATHに明示しない限り「存在しない」扱いになります。plistにも同じPATHを書いています(<key>EnvironmentVariables</key> 以下)が、スクリプト側にも書くのは多重防衛です。plistのPATHはlaunchdがプロセスに渡す環境変数ですが、スクリプト内でサブシェルを起動した場合には引き継がれないことがあります。export PATH を両方に書くのは冗長に見えて、実際には必要な設計です。
find コマンド:4つのフラグが全部要る理由
find "$AUTO" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print 2>/dev/null
このfindは「auto/ 直下のスキルディレクトリだけを列挙する」ための4フラグセットです。各フラグを外したときに何が起きるかを実際に考えると、なぜ全部要るかわかります。
-mindepth 1を外すと $AUTO 自身がヒットし、ループが auto/ ディレクトリ全体を1スキルとして処理しようとします。auto/SKILL.md を探しに行き、なければ continue されるだけですが、ログにノイズが残ります。
-maxdepth 1を外すと .archive/ 配下の退避済みスキルも再びスキャン対象になります。せっかく退避したスキルが「会話ログにヒットしないから再度archive」という無限ループに入り、mv が「移動先に同名ディレクトリが既に存在する」エラーで止まります。
-type dを外すと .curate.log や .curator-proposals.md のファイルもヒットします。basename でファイル名を取り、SKILL.mdの存在確認で걸러されるので実害はゼロですが、ログに無意味な skip エントリが大量に積まれます。
! -name '.*'を外すと .snapshots と .archive がスキャン対象になります。.snapshots には SKILL.md がないので continue されますが、.archive の中身は本物の(退避済み)スキルです。そこに author: auto があるスキルが再度stale/archive判定を受け、すでに .archive/ にいるのに再度 mv "$d" "$ARCH/" しようとし、パスが変になります。
2>/dev/null はmacOSの権限エラーをサイレント化するためです。~/.claude/ 配下の一部ファイルが他プロセスにロックされていると find が Permission denied を吐きますが、それをログに流すと本来の整理ログが埋もれます。
Python インラインという選択の理由
スクリプト内に2箇所、Pythonコードが <<'PY' ヒアドキュメントで埋め込まれています。「なぜ別ファイルの .py にしないのか」と最初に聞かれました。
理由は単一ファイルで自己完結していることです。skill-curate.sh だけを ~/.claude/scripts/ に置けば動く。Pythonスクリプトの配置パスを別途管理する必要がない。スキルキュレーターの設定を人に渡すときも、このファイル1本でいい。
<<'PY' のシングルクォートが重要です。<<PY だとヒアドキュメント内の $d や $md がシェルに展開されてしまい、Python側に正しい文字列が渡りません。シングルクォートで囲むことで、ヒアドキュメントの内容をリテラル文字列としてPythonに渡せます。
日数計算をPythonで書いた理由も明確で、bashの日付計算はmacOSとGNU/Linuxで書き方が違います。date -d はGNU、date -v はBSD。Pythonの time.time() と os.path.getmtime() はクロスプラットフォームで動く(今回はmacOS専用設計ですが、将来の移植コストを下げる)。
stale書き込みの2パターン正規表現
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)
すでに status: フィールドがあるスキルと、ないスキルで処理を分けています。
status: が既にある場合は単純な置換です。active や experimental など何が書いてあっても status: stale に書き換えます。count=1 で最初の1件だけ置換するので、本文中に偶然 status: という文字列が出てきても安全です。
status: がない場合は author: auto の直後に改行で挿入します。なぜ author: auto の直後か。YAMLフロントマターは --- で囲まれたブロックですが、Pythonはそのブロック境界を意識した解析をしていません(文字列として正規表現で処理)。フロントマターの最後の行(---)の前に挿入するのが理想ですが、それをするには --- の位置を探す必要があり、コードが複雑になります。author: auto は必ずフロントマターに存在することが安全ガードで保証されているので、そこを挿入点にするのが最も単純かつ安全です。
LLMフェーズの3つの安全装置
前半でも触れましたが、3つの工夫をそれぞれ深掘りします。
--strict-mcp-config --mcp-config '{"mcpServers":{}}'
これを省略してlaunchdからClaudeを起動すると、Claudeは ~/.claude/claude_desktop_config.json か類似のMCP設定を読み、Obsidian MCPやFigma MCPなどのサーバーを起動しようとします。それらは認証を要求し、UIがない環境では待ち続けるか、タイムアウトして失敗します。MCPを空オブジェクトで上書きすることで、Claude本体だけをシンプルに起動できます。
perl -e 'alarm 600; exec @ARGV'
bashの timeout 600 claude ... とほぼ同じに見えますが、プロセスグループの扱いが違います。timeout はタイムアウト時にSIGTERMを直接の子プロセス(ここでは claude コマンド)に送ります。しかしClaudeは内部でNode.jsのワーカーやサブプロセスを複数起動することがある。SIGTERMが子プロセスだけに届き、孫プロセスが残留することがあります。perl -e 'alarm 600; exec @ARGV' は同じPIDでClaudeを exec するため、シグナルがそのプロセスグループ全体に届きます。
STGステージングディレクトリ
STG=$(mktemp -d -t skill-curate-stg)
( cd "$STG" && ... "$CLAUDE" ... -p "... ./curator-proposals.md ..." )
[[ -f "$STG/curator-proposals.md" ]] && cp "$STG/curator-proposals.md" "$PROP"
rm -rf "$STG"
Claudeに ~/.claude/skills/auto/.curator-proposals.md へ直接書かせると、launchd環境では ~/.claude/ が書き込み保護されているケースで失敗します。mktemp -d で作った一時ディレクトリに cd してから起動することで、Claudeから見たカレントディレクトリが $STG になります。Claudeへの指示には ./curator-proposals.md(相対パス)と書けば、$STG/curator-proposals.md に書かれます。成功したら cp で正規の場所に置き、rm -rf "$STG" で一時ディレクトリを片付けます。
active >= 2 という閾値
if [[ "$RUN_LLM" != "nollm" ]] && (( active >= 2 )) && [[ -x "$CLAUDE" ]]; then
なぜ1件でもLLMを呼ばないのか。「重複・低品質・統合候補を洗い出す」という作業は、比較対象が複数ないと意味をなしません。1件のスキルを読んで「これ低品質では?」と判定させることは技術的には可能ですが、Claude APIを呼び出すコストに見合う価値がない。週次で最大5ドルの上限(--max-budget-usd 5.00)を設けていますが、そもそも呼び出さないのがベストです。
active件数が多いほどLLM提案の価値は上がります。10件のスキルに「これとこれは同じ内容なので統合できます」と言ってもらうのは有用です。
私が詰まった話
詰まり①:launchdで動くが何もしない
症状:ターミナルで手動実行すると正常動作する。launchdに登録して翌週待っても .curate.log が空のまま。
原因:claude コマンドが見つからない。launchd環境のPATHは /usr/bin:/bin:/usr/sbin:/sbin だけです。~/.local/bin/claude はもちろん、~/.nvm/versions/node/v24.13.0/bin/ の node もない。スクリプトが [[ -x "$CLAUDE" ]] で claude の存在確認をして false になり、LLMフェーズをスキップ。しかし整理フェーズは python3 を使っていて、そちらも見つからない場合は days の計算がゼロ(終了コード非ゼロのまま暗黙的に)になっていました。結果、スナップショットは作成され snapshot taken だけ書かれてそこで静止していた。
直し方:plistの EnvironmentVariables に完全なPATHを書き、スクリプトの冒頭でも export PATH する。両方に書くのは冗長に見えますが、launchdが渡す環境変数とスクリプト内で再エクスポートする変数は別のレイヤーです。片方だけでは特定の環境で再現する問題があったため、現在のスクリプトのように両方に書く形が安定しています。
詰まり②:スナップショットが毎週10倍に膨らんでいく
症状:.snapshots/ ディレクトリが3週目に急に重くなった。ls -lh ~/.claude/skills/auto/.snapshots/ を見ると、最新のtar.gzが先週の10倍のサイズ。
原因:--exclude を書いていなかったため、.snapshots/ 自体がtar.gzの中に含まれていた。つまり先週のスナップショット(アーカイブ内のアーカイブ)が今週のtar.gzに丸ごと入り込んでいた。これが毎週再帰的に膨らむ。3週目には「スナップショットのスナップショットのスナップショット」という入れ子構造でディスクを圧迫していました。
直し方:
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
--exclude='auto/.snapshots' と --exclude='auto/.archive' を追加して解決。tarの --exclude はtar.gz内のパスで指定します。-C "$HOME/.claude/skills" でスキルディレクトリに cd した状態でtar化しているため、auto/.snapshots という相対パスで正しく除外されます。
古いスナップショットの削除は現時点で自動化していません。月次で find ~/.claude/skills/auto/.snapshots -name '*.tar.gz' -mtime +60 -delete を手動実行する運用です。これは次の改善課題として残っています。
詰まり③:timeout でClaudeが残留し続けた
症状:LLMフェーズが600秒で止まらず、ps aux | grep claude が翌朝のlaunchd起動時にもClaudeプロセスを表示する。翌週の実行と前週の実行が並走し、$PROP の書き込みが競合してファイルが壊れた。
原因:最初のバージョンでは timeout 600 "$CLAUDE" ... と書いていました。timeout はタイムアウト時にSIGTERMを直接の子プロセスに送ります。しかしClaude CLI(バージョンによる)は内部でNode.jsのworker_threads或いは子プロセスを生成することがあり、SIGTERMが親プロセスのみに届いて孫プロセスが生き残る状況が発生しました。
直し方:
perl -e 'alarm 600; exec @ARGV' "$CLAUDE" ...
exec @ARGV によってperlはClaudeに置き換わり(perlのPIDがClaudeのPIDになる)、alarm シグナルはそのプロセスグループ全体を対象にします。加えて ( cd "$STG" && perl -e ... ) のサブシェル全体をプロセスグループとして扱うことで、残留プロセスが発生しにくい構造になりました。この修正後は翌朝に残留プロセスが確認されなくなっています。
詰まり④:スキル名が短いと会話ログ全体がヒットした
症状:codex というスキル名を付けたものを作ったところ、そのスキルが永久に「使用中(active)」と判定される。会話ログの grep -rl -- "codex" が全ファイルを返してきて、最新mtimeが「今週の会話ログ」になり続ける。
原因:会話ログには「Codexに投げる」「Codexで実装する」という日常的な記述が無数に存在します。スキル名 codex は単語として一般的すぎて、ログ検索がスキル「使用」と「単なる言及」を区別できません。
直し方(と限界):根本的な解決にはなっていません。現時点での対処は、スキル名をできるだけ固有で長くすることです。codex ではなく codex-delegation-handoff のようなハイフン区切りの複合語にすると、grep が完全一致に近い精度でヒットします。grep -rl -- "codex-delegation-handoff" は汎用的な会話ログではほぼヒットしません。
設計的には「会話ログへの言及を使用の代理指標にする」こと自体が近似であり、この精度はその近似の構造的な限界です。完璧な使用ログがAPIレベルで取れない以上、スキル命名の規約でカバーするのが現時点のベストです。
詰まり⑤:staleフラグが本文に書き込まれた
症状:あるスキルの SKILL.md を開くと、フロントマター(--- で囲まれた部分)ではなく本文の中に status: stale が突然書かれていた。YAMLとしては無効で、次回のgrep判定が壊れた。
原因:そのスキルの本文(説明の例示部分)に author: auto の場合は... という文字列が含まれていました。正規表現 r'^(author:[ \t]*auto.*)$' の re.M フラグは全行に対して ^ を行頭として扱います。フロントマター内の author: auto より先に本文内の author: auto の場合は... が出てきた場合、そこにマッチして \nstatus: stale が挿入されます。
直し方:count=1 があるので「最初の1件だけ」置換するという制約はあります。つまり本文が先にくる場合は本文にフラグが入り、フロントマターには入りません。正確な修正は「フロントマター(--- から最初の --- まで)だけを対象にするパーサーを書く」ことですが、コード量が増えます。
現在の現実的な対処は、スキル本文の例示コードに author: auto という文字列を書かないという規約です。コードブロック内のYAML例示であればバッククォートで囲むため行頭にはならず ^ にマッチしませんが、インラインの説明文には注意が必要です。完全な修正はFrontmatterパーサーの実装として積み残しになっています。
自動化がうまく動かない理由は、ほとんどの場合「ツールの思い込み」です。ターミナルで動くからlaunchdでも動く。SIGTERMを送れば必ず止まる。grepで名前を探せば使用を検知できる。どれも直感的に正しく見えて、実際の環境では壊れます。
この仕組みを作った本当の意味は、週次の自動整理よりも、「どこで壊れるか」を事前に考え抜いた設計の思考プロセスを自分のものにすることだったかもしれません。無人で走らせるものを作るたびに、同じ問いが来ます。「これが夜中の4時に失敗したとき、朝起きた自分が何を見れば原因がわかるか」。
それに答え続けることが、自律環境の品質を上げていく唯一の道です。
つまずきポイント
前段で5つの詰まりを深掘りしましたが、実運用ではそれ以外にも細かい落とし穴が積み重なります。実コードと実体験から網羅します。
-
launchctl loadが非推奨なのに通ってしまう。macOS Monterey以降、正しい登録はlaunchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.shun.skill-curate.plist。古いloadコマンドは「Deprecated」と言いながら受け付けることがあり、起動しない症状が再現性なく出ます。登録状態の確認はlaunchctl list | grep skill-curateでPID欄を見る。値がゼロなら「登録済みだが一度も起動していない」です。 -
$LOGSディレクトリが空・不在だと全スキルが未使用扱いになる。スクリプト9行目でLOGS="$HOME/Documents/my-knowledge-base/raw/conversations"と定義しています。このパスが実在しない場合、grep -rl -- "$skill" "$LOGS"はヒットゼロでlastlogが空文字列になります。set -uは「未定義変数の参照」を止める命令であり、「空文字列の変数」には反応しません。Pythonはフォールバックとしてcreated:→mtimeを使いますが、スキルを作った日を最終使用日とみなすため、作ってから30日が経過していれば全スキルが一斉にstaleになります。最初にログ格納パスの実在確認を[[ -d "$LOGS" ]] || { echo "LOGS missing"; exit 1; }で先にはじくべきでした。 -
会話ログにバイナリが混在するとgrepが偽陽性を出す。ログディレクトリに
.pngや添付ファイルが紛れると、grep -rl -- "skill-name"がバイナリにもマッチします。現行のgrepに--include='*.md'や--include='*.txt'は付いていません。会話ログのフォーマット(純テキストのみ)を前提とした設計です。バイナリが混入しているディレクトリを$LOGSに指定するとスキルが「常に最近使われている」と誤判定し続けます。 -
stat -f '%m'はmacOS(BSD)専用構文。スクリプト40行目で使っています。GNU/Linuxのstat --format=%Yとは書き方が違います。今回のlaunchd前提ではmacOS固定なので実害ゼロですが、同じスクリプトをDockerコンテナやLinuxサーバーに持ち込む際は書き換えが必要です。Pythonのos.path.getmtime()がクロスプラットフォームで書かれているのと非対称です。 -
Python
sys.argvの防衛的インデックス補填を外すとIndexErrorでクラッシュ。実コード44行目:lastlog, created, md = (sys.argv + ["","",""])[1:4]+ ["","",""]の補填がないと、bash側で引数が1つでも欠けたときIndexErrorが出て終了コード非ゼロになります。daysが空のままif (( days > ARCHIVE_DAYS ))に入ると算術式エラーでスクリプト全体がそこで止まります。Python側でクラッシュしないようにしておくことで、bash側の防衛ラインを薄くしても安全な構造にしています。 -
--max-budget-usd 5.00で途中打ち切りになると proposals.md が不完全。LLMが5ドルの上限に達した時点でClaudeが強制終了され、$STG/curator-proposals.mdは書き出し途中のMarkdownになります。スクリプトは[[ -f "$STG/curator-proposals.md" ]]でファイルの「存在」だけを確認してcpするため、内容が壊れていても上書きされます。重要な提案を見落とすリスクはありますが、上限を設けない場合は週次バッチでAPIコストが青天井になります。5ドルは「提案ファイルが壊れても痛くない上限」として設定しています。 -
com.shun.skill-curate.logはローテーション設定なし。plistのStandardOutPathとStandardErrorPathは同じファイル~/.claude/logs/com.shun.skill-curate.logを指しています(plist実装より)。週次実行のたびに追記されるため、1年放置すると数十MBになります。macOSのnewsyslog設定も未実装です。現状はtail -100 ~/.claude/logs/com.shun.skill-curate.logで直近のみ確認する運用です。 -
スナップショットの削除を自動化していないので積み上がる。現行スクリプトはスナップショットを作るだけで削除しません。月次で手動実行が必要です:
find ~/.claude/skills/auto/.snapshots -name '*.tar.gz' -mtime +60 -deleteこの1行を月次launchdに仕込む(plistで
Weekdayを省いてDay: 1= 毎月1日)のが次の改善ステップです。 -
launchd二重起動時に
.curator-proposals.mdの書き込みが競合。LLMフェーズが600秒を超えて翌週のStartCalendarIntervalが発火すると、2インスタンスが並走します。.curate.logは append なのでファイルは壊れませんが、$PROPファイルには2プロセスが同時にcpを走らせます。現行実装にflockによるロックファイルはありません。並走に気づく手がかりは.curate.logにcurate doneが2行連続で現れることです。 -
author:の書き換えを忘れると意図せずarchiveされる。残したいスキルを保護するにはauthor:フィールドをauto以外に変えるだけで済みます(例:author: manual)。しかし「あとで変えよう」と後回しにすると忘れます。90日後に静かに.archive/へ移動されて気づかない、という事態になります。スキルを作った直後にauthor:を確認する習慣が必要です。 -
代理指標の検証を怠ると誤判定に気づけない。「会話ログへの言及を使用とみなす」近似がどの程度の精度で動いているかを定期確認するのは重要です。検証は1コマンドでできます:
grep -rl -- "skill-name" ~/Documents/my-knowledge-base/raw/conversations/ | wc -lヒット数がゼロなら「本当に使われていないか、スキル名がログに書かれていないか」のどちらかです。ゼロでも
.archive/退避は.snapshots/からいつでも戻せるため、精度が低くても取り返しはつきます。
ベストプラクティス
実装と運用から導いた、再現性のある指針です。
1. スキル名は固有・長くハイフン区切りにする
codex より codex-delegation-handoff。grep -rl -- "codex-delegation-handoff" は汎用会話ログにほぼヒットしません。使用代理指標の精度はスキル命名の規約で半分決まります。新しいスキルを作るとき、skill-name が一般的な英単語かどうか を先に確認する習慣が精度の基盤です。
2. author: auto フラグを保護機構の唯一の旗にする
「機械が生成した整理対象」を示すフラグは1種類だけにしておく。それ以外のスキルは author: manual でも author: lily でも、auto でなければ実コード34〜37行目の安全ガードで無条件スキップされます。フラグを増やしたり条件を複雑にしたりすると、保護ロジックが壊れた時の影響範囲が広がります。
3. set -u はバッチスクリプトの最初の1行
未定義変数が float() に渡ると Pythonが黙って0を返し、全スキルが「1970年から未使用」扱いになります。set -u があれば未定義変数の参照でスクリプトが即停止し、ログに行番号が残ります。原因調査の時間が10分から30秒に縮まります。
4. PATH は plist と スクリプトの両方に書く
plist の EnvironmentVariables/PATH はlaunchdがプロセスに渡す環境変数です。スクリプト冒頭の export PATH はサブシェル起動時に引き継ぐための再エクスポートです。どちらか片方だけでは「特定の環境でサブシェルのclaude/python3が見つからない」症状が再現します。現行の plist と skill-curate.sh 冒頭は両方に同じパスを書いています。
5. perl -e 'alarm 600; exec @ARGV' でタイムアウトを完全に掛ける
bash の timeout は直接の子プロセスにSIGTERMを送ります。Claudeが内部でworker_threadsやサブプロセスを生成した場合、孫プロセスが生き残ります。perl alarm exec はperlがClaudeに置き換わる(同じPIDで exec)ため、シグナルがプロセスグループ全体に届きます。翌週のlaunchd起動時にClaudeが残留している、という状況を防げます。
6. LLMにはステージングディレクトリのカレントで書かせる
mktemp -d で一時ディレクトリを作り、そこに cd してからClaudeを起動します。Claudeへの指示に ./curator-proposals.md(相対パス)と書くことで、~/.claude/ への直接書き込み権限を与えずに出力を受け取れます。[[ -f "$STG/curator-proposals.md" ]] でファイルの存在確認後に cp、最後に rm -rf "$STG" で片付ける。この3ステップがステージング経由の定石です。
7. --max-budget-usd 5.00 でLLMコストに上限を設ける
週次バッチのLLMフェーズは「提案を書く」だけで、実際にスキルを変更しません。仮に提案ファイルが不完全でも翌週再実行されます。このタスクに5ドル超の課金は不要です。上限値はタスクの「失敗しても痛くない最大コスト」で決めます。
8. --strict-mcp-config --mcp-config '{"mcpServers":{}}' でMCPを無効化する
launchdからClaudeを起動すると、インタラクティブなMCPサーバーが認証待ちになります。UIのない環境では永遠に待ち続けるか、タイムアウトしてLLMフェーズ全体が失敗します。空のMCP設定を上書きすることで、Claude本体だけをクリーンに起動できます。週次バッチで外部サービスに接続する必要はありません。
9. find の4フラグセットは崩さない
find "$AUTO" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print
-mindepth 1($AUTO 自身を除外)、-maxdepth 1(.archive/ 配下の再スキャンを防ぐ)、-type d(ファイルをスキルと誤認しない)、! -name '.*'(.snapshots .archive をスキャンしない)。4つのうち1つでも外すと無害に見えて後で気づきにくいバグが生まれます。
10. スナップショットの --exclude は .snapshots と .archive の両方
片方だけ書くと再帰肥大か、退避済みスキルの二重退避が起きます。実コード24〜26行目のように両方を明示します:
tar czf "..." --exclude='auto/.snapshots' --exclude='auto/.archive' auto
-C "$HOME/.claude/skills" で作業ディレクトリを移動した上で auto を指定しているため、--exclude のパスは auto/.snapshots という相対形式です。
11. nollm 引数でLLMなしテストができる口を残す
RUN_LLM="${1:-llm}" で第1引数が nollm のときLLMフェーズをスキップできます。初回セットアップや設定変更後の動作確認は ~/.claude/scripts/skill-curate.sh nollm で走らせれば、スナップショット取得・stale/archive判定だけを検証できます。Claude APIを呼ばないのでコスト・時間・残留プロセスのリスクがゼロです。
12. 二重ログ設計で「起動したか」と「何をしたか」を分離する
launchdのログ(~/.claude/logs/com.shun.skill-curate.log)はプロセス起動レベルのエラーを記録します。スクリプト内のアプリログ(~/.claude/skills/auto/.curate.log)はスナップショット取得・stale判定・archive操作を記録します。問題診断の順序は「launchdログに何もない→スクリプトが起動していない」「launchdログはあるがアプリログが snapshot taken で止まっている→その先でエラー」と二段階で絞り込めます。
13. リストアコマンドを ~/.claude/scripts/ にメモしておく
運用中に「archiveしたスキルを戻したい」は必ず起きます。コマンドをその場で考えなくて済むように、手元に用意しておきます:
# 直近スナップショットからフル復元
tar xzf ~/.claude/skills/auto/.snapshots/auto-YYYYMMDD-HHMMSS.tar.gz \
-C ~/.claude/skills/
# 特定スキルだけ .archive/ から戻す
mv ~/.claude/skills/auto/.archive/skill-name ~/.claude/skills/auto/
14. 代理指標の誤判定は .archive/ の非破壊性でカバーする
「会話ログへの言及を使用とみなす」近似の精度は完璧ではありません。設計の正直な評価として、この近似は「完璧な使用ログより運用可能な指標を選ぶ」という判断です。誤ってarchiveされたスキルは mv で1秒で戻せます。精度より可逆性を先に固める。これが自律バッチ設計の基本的な姿勢です。
まとめ
skill-curate.sh と com.shun.skill-curate.plist がやっていることは、突き詰めると3つです。実行前にスナップショットを取る・author: auto 以外には触れない・削除でなく移動で退避する。この3原則があるから、精度が低い代理指標でも週次で走らせ続けられます。
代理指標の限界を知っていてもなお使うのは、「完璧な設計」より「毎週動く不完全な仕組み」の方が価値があるからです。週次でスナップショットが積み上がり、staleフラグが更新され、LLM提案が書き出される。ログを見れば先週何が起きたかわかる。これが自律環境の「記憶」です。
Claude Codeは使えば使うほど知識が溜まる。しかしその知識を整理する仕組みがなければ、最終的にはコンテキスト圧迫という形でその知識が性能を食い始めます。auto-skillのキュレーション自動化は、「環境の環境を整備する」メタレイヤーです。月商120万の仕事の大半は、個別タスクをこなすより次の100タスクが自動化される仕組みを作ることです。この週次スクリプトはその考え方の、最も地味で最も効いた実装例のひとつです。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!