🪦 🪦 一度も呼ばれていないエージェントを自動発見して整理する — リーダー×
🪦

🪦 一度も呼ばれていないエージェントを自動発見して整理する

#automation#claudecode#副業2026-09-01 · 約33

月商120万を支えているのは、AIへの指示の上手さではありません。「定義した機能が本当に動いているか」を自動で確認し続ける環境設計です。

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

Claude Codeには、~/.claude/agents/ ディレクトリに .md ファイルを置くことでカスタムエージェントを定義できます。architect(アーキテクチャ設計)、code-reviewer(コードレビュー)、security-reviewer(セキュリティ監査)のような専門エージェントを定義して、Claude Codeが自律的に使い分けてくれることを期待する——これは自然な発想です。

ところが、実際のログを集計してみると驚くような結果が出ます。

私の環境を例に挙げます。~/.claude/agents/ には現在8つのエージェント定義ファイルがあります。

architect.md
code-reviewer.md
database-reviewer.md
INDEX.md
planner.md
python-reviewer.md
security-reviewer.md
typescript-reviewer.md

~/.claude/logs/agent-invocations.jsonl に記録されている2026年5月28日から8月30日までの682件のログを集計すると、直近30日間の呼び出し内訳はこうなります。

=== Agent usage (last 30d) ===
total invocations: 23  unique types: 3

Top 10:
  agent                                     calls  errors
  Explore                                      19       0
  general-purpose                               3       0
  code-reviewer                                 1       0

0-call agents (defined locally but not used in 30d): 7
  - INDEX
  - architect
  - database-reviewer
  - planner
  - python-reviewer
  - security-reviewer
  - typescript-reviewer

定義済み8エージェントのうち、30日間で1回でも呼ばれたのは code-reviewer の1件のみです。残り7つはゼロ呼び出し。定義したエージェントの87.5%が、実際には存在していないも同然の状態でした。

直近7日間に絞るとさらに深刻で、code-reviewer も圏外に落ち、0回エージェントが8つに増えます。

=== Agent usage (last 7d) ===
total invocations: 3  unique types: 2

0-call agents (defined locally but not used in 7d): 8
  - INDEX
  - architect
  - code-reviewer
  - database-reviewer
  - planner
  - python-reviewer
  - security-reviewer
  - typescript-reviewer

これは単なる「もったいない」の話ではありません。Claude Codeのエージェント定義はシステムプロンプトとして常時注入されますarchitect.md のような大型エージェントを開くと220行を超える定義が入っています。使われもしない7つのエージェント定義がトークンを消費し、推論の精度に影響を与え続けているわけです。

「定義した=機能している」という思い込み

エージェントを定義した瞬間の達成感は本物です。「これで次からコードを書いたら自動でレビューしてもらえる」「アーキテクチャを考えるときに専門家が動く」——そう信じたまま数週間が経過します。

ところが現実のClaudeは、エージェントを明示的に指定しない限り汎用ルート(general-purpose)かExploreを選びます。code-reviewerの定義説明に「MUST BE USED for all code changes」と書かれていても、それは定義内の文章であり、Claudeが自律的にその指示を読んで行動するわけではありません。呼び出す側のプロンプトか、呼び出し元のロジックが存在して初めて機能します。

未使用エージェントは2つの意味でコストを発生させます。

トークンコスト。 システムプロンプトに常時注入されるエージェントカタログの長さは、呼び出しのたびに消費されます。定義ファイルが増えるほど1リクエストあたりのトークン数が増加し、大きなコンテキストウィンドウを食い潰します。

認知コスト。 「どのエージェントが実際に機能しているか」を人間が手動で把握するのは難しく、定義ファイルが増えるほど管理が複雑になります。実態を反映しない定義が積み重なると、環境の信頼性が下がります。「このエージェントは本当に動いているのか?」という疑いが生まれた瞬間、自律環境への信頼は揺らぎます。

解決の方向性:実測値で定義を刈り込む

解決策は感覚ではなく実数値で判断することです。ログに基づいて「過去30日間で一度も呼ばれていないエージェント」を自動的に洗い出し、削除または整理する運用サイクルを作ります。

この発想のポイントは作業ではなく環境に投資することです。「今日このエージェントを削除する」という1回の作業ではなく、「いつでも未使用エージェントを即座に確認できるスクリプトが存在している」という状態を作ります。スクリプトは月次で回す、週次で回す、あるいはcronで定期実行する——運用の頻度はあとから決められます。重要なのは、実測値に基づいて判断できる状態に常時あることです。

私が月商120万の自律環境を半年で構築できたのも、「AIが賢くなる」ことへの期待ではなく、「AIが間違った方向に動いていないか」を常時監視する仕組みへの投資が中心にあります。エージェント使用率の監視は、その一例です。

全体の流れ

仕組みは3層に分かれています。

┌─────────────────────────────────────────────────────────┐
│  Layer 1: 記録                                          │
│  Claude Codeのstop hookが                               │
│  エージェント呼び出しをJSONLへ書き出す                  │
│                                                         │
│  ~/.claude/logs/agent-invocations.jsonl                 │
│  → 1行1レコード / ts・session_id・subagent_type等       │
└────────────────────┬────────────────────────────────────┘
                     │
                     ▼
┌─────────────────────────────────────────────────────────┐
│  Layer 2: 集計                                          │
│  agent-usage-summary.sh が指定期間のレコードを集計      │
│                                                         │
│  - Bash外殻(引数パース・環境変数セット)               │
│  - Python3ヒアドキュメント(ロジック本体)              │
│    ├ ウィンドウ期間でフィルタ                           │
│    ├ subagent_type別にカウント                         │
│    └ ~/.claude/agents/*.md と突き合わせ               │
└────────────────────┬────────────────────────────────────┘
                     │
                     ▼
┌─────────────────────────────────────────────────────────┐
│  Layer 3: 出力                                          │
│  Top10呼び出しランキング + 0回エージェント一覧         │
│                                                         │
│  → 削除・アーカイブ・再設計の判断材料になる             │
└─────────────────────────────────────────────────────────┘

Layer 1: stop hookによるJSONL記録

Claude Codeには、エージェント呼び出しが完了したタイミングで任意のスクリプトを実行できるstop hookがあります。このhookを使い、呼び出されたエージェントの情報をJSONLファイルへ追記します。

実際のログレコードはこのような形式です。

{"ts": "2026-08-25T01:32:02.235Z", "session_id": "d82e3fca-d397-4f40-8268-34bdeb9de46a", "cwd": "/dev/affiliate-fc2", "tool_use_id": "toolu_01HQ6HRVEgvnNjqDrmejPZ4S", "subagent_type": "general-purpose", "description": "Find CTA redirect click data for fc2 lane", "duration_ms": 3407, "status": "ok", "caller": {"type": "direct"}}
{"ts": "2026-08-30T08:55:08.361Z", "session_id": "36b40280-ff53-4f66-9582-aa09b7fbec80", "cwd": "/dev/note-autolike", "tool_use_id": "toolu_01RjC237NX1QwsWzVUMqHbvY", "subagent_type": "Explore", "description": "Survey note paid-article infra", "duration_ms": 236, "status": "ok", "caller": {"type": "direct"}}

ts(タイムスタンプ)、subagent_type(エージェント種別)、status(ok/error)が集計に使う主要フィールドです。duration_ms があるため、呼び出しごとの所要時間もわかります。私の環境では現在682件が蓄積されており、2026年5月28日の初回記録から3ヶ月分のトラッキングデータになっています。

Layer 2: agent-usage-summary.sh の構造

集計スクリプトは103行です。Bashの外殻で引数を受け取り、ロジックはPython3のヒアドキュメントで書いています。理由は2つ——BashだけではJSONLのパースが煩雑になること、Pythonのみでは引数処理のシェル統合が面倒なことです。両方の得意領域を使い分ける構成です。

スクリプト全体を示します。

#!/usr/bin/env bash
# agent-usage-summary.sh — Stop hook が記録した agent 呼び出しを集計
#
# 使い方:
#   agent-usage-summary.sh           # デフォルト 7d
#   agent-usage-summary.sh 30d       # 30日
#   agent-usage-summary.sh 7d 30d    # 両方

set -uo pipefail

LOG="$HOME/.claude/logs/agent-invocations.jsonl"
AGENTS_DIR="$HOME/.claude/agents"

WINDOWS=("$@")
if [ ${#WINDOWS[@]} -eq 0 ]; then
    WINDOWS=("7d")
fi

if [ ! -f "$LOG" ]; then
    echo "no log yet: $LOG"
    exit 0
fi

export LOG_PATH="$LOG"
export AGENTS_DIR_PATH="$AGENTS_DIR"
export WINDOWS_CSV="$(IFS=,; echo "${WINDOWS[*]}")"

python3 - <<'PY'
import os, json, datetime, glob, sys
from collections import Counter

log_path = os.environ["LOG_PATH"]
agents_dir = os.environ["AGENTS_DIR_PATH"]
windows = os.environ["WINDOWS_CSV"].split(",")

def parse_window(s):
    s = s.strip().lower()
    if s.endswith("d"):
        return datetime.timedelta(days=int(s[:-1]))
    if s.endswith("h"):
        return datetime.timedelta(hours=int(s[:-1]))
    raise ValueError(f"bad window: {s}")

now = datetime.datetime.now(datetime.timezone.utc)

records = []
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
    for line in f:
        try:
            r = json.loads(line)
        except Exception:
            continue
        ts = r.get("ts", "")
        try:
            dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=datetime.timezone.utc)
        except Exception:
            continue
        r["_dt"] = dt
        records.append(r)

# 既知 agent 一覧(ローカル定義の md ファイル名から推定)
known_agents = set()
if os.path.isdir(agents_dir):
    for fp in glob.glob(os.path.join(agents_dir, "*.md")):
        known_agents.add(os.path.splitext(os.path.basename(fp))[0])

for w in windows:
    try:
        td = parse_window(w)
    except Exception as e:
        print(f"[skip {w}]: {e}")
        continue
    cutoff = now - td
    recent = [r for r in records if r["_dt"] >= cutoff]
    counts = Counter(r.get("subagent_type", "") for r in recent if r.get("subagent_type"))
    errors = Counter(r.get("subagent_type", "") for r in recent if r.get("status") == "error")

    print(f"\n=== Agent usage (last {w}) ===")
    print(f"total invocations: {len(recent)}  unique types: {len(counts)}")
    if counts:
        print("\nTop 10:")
        print(f"  {'agent':<40} {'calls':>6}  {'errors':>6}")
        for name, n in counts.most_common(10):
            err = errors.get(name, 0)
            print(f"  {name:<40} {n:>6}  {err:>6}")

    if known_agents:
        used = set(counts.keys())
        unused = sorted(known_agents - used)
        print(f"\n0-call agents (defined locally but not used in {w}): {len(unused)}")
        for name in unused[:30]:
            print(f"  - {name}")
        if len(unused) > 30:
            print(f"  ... and {len(unused) - 30} more")
    else:
        print(f"\n(no local agents dir at {agents_dir}; cannot list 0-call agents)")
PY

設計のポイントを3つ挙げます。

複数ウィンドウを1コマンドで比較できる。 agent-usage-summary.sh 7d 30d と実行すると、7日間と30日間の結果が連続して出力されます。「30日では使っていたが7日では0回」という傾向変化も一目で掴めます。

既知エージェントの突き合わせは glob で行う。 ~/.claude/agents/*.md のファイル名から拡張子を除いたものを「定義済みエージェント」として扱います。新しいエージェントを追加しても、スクリプト本体を修正せずに自動で検出対象に加わります。

エラーカウントも同時に出力する。 status: "error" のレコードを別集計しており、「呼ばれてはいるが毎回失敗している」エージェントも可視化できます。呼び出し数だけでなく成功率まで把握することで、「動いているが壊れている」という別種の問題も発見できます。

Layer 3: 出力の読み方と判断フロー

スクリプトの出力は2ブロックに分かれています。Top 10ランキング0-callリストです。

=== Agent usage (last 30d) ===
total invocations: 23  unique types: 3

Top 10:
  agent                                     calls  errors
  Explore                                      19       0
  general-purpose                               3       0
  code-reviewer                                 1       0

0-call agents (defined locally but not used in 30d): 7
  - INDEX
  - architect
  - database-reviewer
  - planner
  - python-reviewer
  - security-reviewer
  - typescript-reviewer

このランキングから読み取れる事実はシンプルです。23回の呼び出しのうち19回(82.6%)はExploreです。Exploreはファイル検索・コード調査に特化した汎用エージェントであり、カスタム定義ではなくClaude Code組み込みの機能です。つまり「カスタムエージェントを7つも定義したのに、実際には汎用機能しか使われていない」という状態が3ヶ月続いていたことが数値で確認できます。

0-callリストに名前が出たエージェントに対して、判断は3択です。

削除する。 明らかに使われていない、かつ呼び出す仕組みも構築していない場合は削除します。システムプロンプトのトークン節約と、環境の見通しの良さが即座に改善されます。

アーカイブする。 将来使う可能性があるが今は不要な場合は ~/.claude/agents/archive/ に移動します。glob のパスを *.md で絞っているため、サブディレクトリに移動するだけで自動的に集計から除外されます。

呼び出し元を作る。 エージェントの機能自体は価値があるが「呼ばれていない」だけの場合、stop hookや特定のプロンプトパターンでそのエージェントを明示的に呼び出す仕組みを追加します。この場合も数値がなければ「価値があると思い込んでいるだけ」の状態なので、実装後に改めて集計して効果を確認します。

実装の詳細

前半で全体図を示しました。ここからは2つのスクリプト——stop_agent_tracker.sh(記録)とagent-usage-summary.sh(集計)——のコードを深く読みます。「なぜこう書くのか」と「どこが肝なのか」に絞ります。

stop_agent_tracker.sh:2パス構造が核心

stop hookが受け取るのは、セッション終了時にClaude Codeが標準入力へ流してくる以下のようなJSONです。

{"session_id":"36b40280-...","transcript_path":"/.../.claude/projects/.../transcript.jsonl","cwd":"/dev/note-autolike","hook_event_name":"Stop"}

transcript_path はそのセッション全体の会話ログへのパスです。エージェントが何を呼んだかはそこに書いてあります。ただし、1件のエージェント呼び出しは 2つの別々の行 に分かれて記録されています——呼び出した時点の tool_use(種別・引数)と、処理完了後の tool_result(成否・出力)です。この2行を突き合わせて初めて「何を、いつ、成功したか」が分かります。

スクリプトはそのために2パス処理を採用しています。

# 第1パス: 全 tool_use と tool_result をインデックス化
uses   = {}   # tool_use_id -> (ts, name, input, caller)
results = {}  # tool_use_id -> (ts, is_error)

with open(tp, "r", encoding="utf-8", errors="replace") as f:
    for line in f:
        rec = json.loads(line)
        for b in content:
            if btype == "tool_use" and b.get("name") == "Agent":
                inp = b.get("input") or {}
                if "subagent_type" not in inp:
                    continue
                uses[uid] = (ts, b.get("name"), inp, b.get("caller"))
            elif btype == "tool_result":
                results[rid] = (ts, bool(b.get("is_error")))

1パス目でtranscript全行を走査し、usesresultsを別々の辞書に積みます。2パス目ではusesを軸にresultsを引いてマッチングし、JSONLを書き出します。

ここで1つ重要な設計判断があります。マッチしなかった(resultsにキーがない)tool_useは、status: "pending" として記録します

res = results.get(uid)
if res:
    res_ts, is_error = res
    status = "error" if is_error else "ok"
else:
    res_ts, status = None, "pending"

Claude Codeがhookを呼ぶのはセッション終了時です。セッション途中で強制終了した場合や、tool_resultが書き込まれる前にセッションが切れた場合、pendingとして残ります。これがあることで「記録されているが完了していない」呼び出しを区別できます。

duration_ms の計算も2パス構造の恩恵です。

t0 = parse_ts(use_ts)   # tool_use のタイムスタンプ
t1 = parse_ts(res_ts)   # tool_result のタイムスタンプ
if t0 and t1:
    duration_ms = int((t1 - t0).total_seconds() * 1000)

tool_useとtool_resultそれぞれのタイムスタンプ差がエージェントの実行時間です。実際のログを確認すると、Exploreの236msに対してgeneral-purposeは3407ms〜6830msかかっています。この差がそのままトークンコストの差に近似します。

重複防止ロジックも見逃せません。stop hookは1セッションで複数回発火することがあります(Claude Codeの再起動・強制終了後の再接続など)。同じtool_use_idを2回書くと集計が狂います。

seen_ids = set()
if os.path.exists(out_path):
    with open(out_path, "r", encoding="utf-8", errors="replace") as f:
        for line in f:
            r = json.loads(line)
            if r.get("session_id") == sid and r.get("tool_use_id"):
                seen_ids.add(r["tool_use_id"])

書き出し前にJSONLを全行スキャンし、同じsession_id内のtool_use_idをSetに積んでおきます。書き出しループではseen_idsに含まれるものをスキップします。682件のログで一度も重複が出ていないのは、この処理が機能しているからです。

agent-usage-summary.sh:Bash外殻+Python内核の分割理由

この設計を見て「Pythonだけで書けばいい」と思う人もいるはずです。私も最初はそう思いました。

Bash外殻を残した理由は2つあります。

1つ目:引数処理の柔軟性。 "$@" を使うことで、7d 30d のような複数引数を自然に扱えます。Pythonだと sys.argv を自分でパースする必要があり、配列の扱いがやや煩雑です。

2つ目:環境変数経由のデータ受け渡し。 ヒアドキュメント(<<'PY')内のPythonは os.environ でBash側の変数を受け取ります。

export LOG_PATH="$LOG"
export AGENTS_DIR_PATH="$AGENTS_DIR"
export WINDOWS_CSV="$(IFS=,; echo "${WINDOWS[*]}")"

WINDOWS_CSV の生成部分——IFS=,; echo "${WINDOWS[*]}"——が肝です。Bash配列を,区切りの文字列に変換してからPython側に渡し、Python側で.split(",")して戻します。Bashの配列はヒアドキュメント内に直接渡せないため、一旦文字列にエンコードする橋渡しが必要です。

Python側の parse_window は文字列を timedelta に変換します。

def parse_window(s):
    s = s.strip().lower()
    if s.endswith("d"):
        return datetime.timedelta(days=int(s[:-1]))
    if s.endswith("h"):
        return datetime.timedelta(hours=int(s[:-1]))
    raise ValueError(f"bad window: {s}")

現時点ではd(日)とh(時間)だけ対応しています。w(週)やm(月)を追加したい場合も、この関数だけ変更すれば全体が動きます。

既知エージェントの自動検出も重要な設計です。

known_agents = set()
if os.path.isdir(agents_dir):
    for fp in glob.glob(os.path.join(agents_dir, "*.md")):
        known_agents.add(os.path.splitext(os.path.basename(fp))[0])

~/.claude/agents/ 直下の *.md ファイル名(拡張子除く)を全部Setに入れます。私の環境では archive/ サブディレクトリにファイルを移動するだけで、そのエージェントは集計対象から外れます。スクリプト側の修正は不要です。現在 archive/ ディレクトリ(2024年8月29日作成)に以前のエージェント定義が移動されています。

私が詰まった話

実際に動くまでの経緯を3つ書きます。どれも「動くはずなのになぜか記録されない」系の問題で、症状から原因にたどり着くまでに時間がかかりました。

詰まり①:エージェントのツール名が「Task」ではなく「Agent」

最初に書いたコードはtranscriptから name == "Task" を探していました。Claude Codeの外向きのAPIでは「Task」という名前でエージェント呼び出しが紹介されていたからです。

しかし実際にtranscriptを直接開いて中身を確認すると、全レコードに "name": "Agent" と書かれていました。

{"type": "tool_use", "name": "Agent", "input": {"subagent_type": "Explore", ...}}

name == "Task" でフィルタしていたため、全件スルーして何も記録されない状態が2日続きました。grep '"name"' ~/.claude/projects/*/transcript.jsonl | head -5 で直接確認して初めて気づきます。ドキュメント表記と実ファイルの乖離です。

修正は1行でした。

# 修正前
if btype == "tool_use" and b.get("name") == "Task":
# 修正後
if btype == "tool_use" and b.get("name") == "Agent":

教訓は「ドキュメントを信じず実ファイルを読め」。transcript.jsonlは普通のJSONLなのでいつでも直接確認できます。

詰まり②:subagent_typeinput.subagent_type にある

tool_useの構造を正確に把握しなかったことで生まれた問題です。最初は b.get("subagent_type") で取ろうとしていました。これでは常に None が返ります。

実際のtranscriptレコードを改めて確認すると、こうなっています。

{
  "type": "tool_use",
  "id": "toolu_01RjC237NX1QwsWzVUMqHbvY",
  "name": "Agent",
  "input": {
    "subagent_type": "Explore",
    "description": "Survey note paid-article infra",
    "prompt": "..."
  }
}

subagent_typeinput の中にあります。取り方は b.get("input", {}).get("subagent_type") が正解です。現在のコードでは inp = b.get("input") or {} として先にinputを取り出し、そこから inp.get("subagent_type") で取得しています。

問題が起きたとき、682件あるはずのログが最初は0件でした。if "subagent_type" not in inp: continue というガード節が全件を弾いていたからです。デバッグには CC_AGENT_TRACKER_DEBUG=1 環境変数を設定してdebugログを有効化しました。

CC_AGENT_TRACKER_DEBUG=1 bash ~/.claude/hooks/stop_agent_tracker.sh <<< '...'

stop_agent_tracker.logrecorded=0 total_uses=0 が出て、「tool_useが1件も認識されていない」ことが確認できました。そこでtranscriptのraw JSONを直接開いてinputの構造を確認し、1行で直りました。

詰まり③:set -uo pipefail でWINDOWS配列が空のとき死ぬ

set -uo pipefail は未定義変数参照を即座にエラーにします。これ自体は正しい設定なのですが、引数なしで呼んだときに ${#WINDOWS[@]} が0を返す前に ${WINDOWS[*]} を参照しようとしてエラーが出ました。

具体的には最初のコードがこうでした。

WINDOWS=("$@")
export WINDOWS_CSV="$(IFS=,; echo "${WINDOWS[*]}")"  # 空配列で問題発生

引数なしで呼ぶと WINDOWS が空配列になります。-u フラグがある環境では空配列の展開がエラーになることがあり(zshとbashで挙動が微妙に異なります)、WINDOWS_CSV が空になるか、最悪スクリプトが終了します。

修正は、WINDOWS_CSV のエクスポート前にデフォルト値を入れることです。

WINDOWS=("$@")
if [ ${#WINDOWS[@]} -eq 0 ]; then
    WINDOWS=("7d")
fi
export WINDOWS_CSV="$(IFS=,; echo "${WINDOWS[*]}")"

空チェックを先に済ませ、デフォルト値を代入してからエクスポートする。set -u 環境では「変数を使う前に必ず値を確定させる」という単純な規則を守るだけです。

この問題はcronで自動実行したときに初めて表面化しました。手動実行では引数を渡すので気づかず、cron定義に引数なしで書いていたため、毎晩サイレントに失敗し続けていたという話です。tail -20 /var/log/... を確認して初めて「1件も記録されていない夜がある」と気づきました。

ログに残る最初のレコードのセッションIDが TEST-AGENT-TRACKER-001 という文字列になっているのも、デバッグ過程の産物です。hookが正しく動くか手動でダミーデータを流してテストした際の記録がそのまま682件の先頭に居座っています。本番ログとテストデータが混在しているのは不格好ですが、集計ロジックはタイムスタンプで期間フィルタするので実害はありません。

詰まり④:code-reviewer が定義内の「MUST BE USED」を読まない

これは技術的なバグではなく、そもそもの誤解からくる詰まりです。

code-reviewer.md の冒頭のdescriptionには MUST BE USED for all code changes と書いてあります。エージェントを定義した当初、「これでコード変更のたびに自動でレビューが走る」と信じていました。

ところが実際の30日集計では code-reviewer の呼び出しは1回だけです。

Claudeがエージェントを選ぶ仕組みを改めて確認すると、descriptionは「どのエージェントを選ぶか判断するためのヒント」であり、「このエージェントを強制的に呼ぶ命令」ではありません。呼び出し側のプロンプトかhookで明示的に指定しない限り、Claudeは汎用ルートを選びます。

MUST BE USED という強い文言は、エージェントを選んだあとにその中で守るべきルールとして機能します。エージェント外部からの強制起動には効きません。

これを理解した上でやるべきことは2択です。①「コード変更後は code-reviewer を使え」という指示をstop hookかプロンプトテンプレートに組み込む、②または「どうせ使わないなら消す」。

私は現時点で②を選んでいます。code-reviewer は30日間で1回しか呼ばれておらず、その1回は手動で明示した場合です。仕組みを作らない限り呼び出し数は増えません。323行の定義ファイルがシステムプロンプトを占有し続けるコストのほうが、「将来使うかも」という期待値を上回ると判断しました。

この判断を下せたのも、数値があったからです。「30日1回」という事実がなければ、「もしかしたら動いているかもしれない」という曖昧な期待が残り続けました。


実装と失敗の話をまとめます。

スクリプト2本で完結するこの仕組みの肝は、「確認したいときにいつでも実数値が出る」という状態を維持することです。数値があれば判断できます。数値がなければ期待で動き続け、無駄なトークンとシステムプロンプトの肥大化が静かに積み重なります。

定義ファイルを増やすより、呼ばれているかを測る仕組みを先に作る——この順序が、Claude Codeを自律環境として育てるときの基本姿勢です。

つまずきポイント

前半・中段で4つの詰まりを書きました。ここでは「環境固有」「運用フェーズ」「解釈ミス」という切り口で、追加の詰まりポイントを列挙します。実際に踏んだものだけです。


cronから実行するとPATHが死んでいる。

手動実行では動くのに、cron定義で実行すると python3: command not found で落ちる。cronの実行環境には ~/.zshrc~/.profile が読み込まれず、PATH/usr/bin:/bin 程度しかない。/usr/local/bin/python3 や nvm 配下の node が見えなくなる。対策は2つ——スクリプト冒頭で PATH をハードコードするか、cron定義の先頭行に PATH=/usr/local/bin:/usr/bin:/bin を書く。私は後者を採用しています。HOME も設定されていないケースがあるため、~/ ではなく /Users/自分のアカウント/ の絶対パスをcronに書く必要があります(スクリプト本体の $HOMEHOME 環境変数が設定されていれば動くので問題なし)。

JSONL 1行が壊れていてもスクリプト全体を止めない。

682件のログを蓄積していると、途中に不完全なJSON行が混入することがあります。Claude Codeがhookを呼び出す途中でセッションが切れた場合、最後のレコードが中途半端になることがあります。json.loads をtry/exceptで囲まずに書くと、1行の破損でスクリプト全体が死にます。集計スクリプトの現行コードでは except Exception: continue でスキップしており、壊れた行を無視して残りを処理します。「こんな場合は絶対に来ない」と思いtry/exceptを省略すると、3ヶ月後のログが増えた頃に初めて落ちます。

glob("*.md") がアーカイブディレクトリも拾う。

glob.glob(os.path.join(agents_dir, "*.md"))~/.claude/agents/ 直下.md ファイルだけを対象にします。archive/ サブディレクトリに移動したファイルは含まれません——これは意図通りです。ところが glob.glob(os.path.join(agents_dir, "**/*.md"), recursive=True) に書き換えると、アーカイブ済みのエージェントも「定義済み」として扱われ、0-callリストに再び名前が出ます。「アーカイブしたのに消えていない」という状態になります。recursive=True は付けないのが正解です。

INDEX.md がエージェントとして誤検出される。

私の ~/.claude/agents/INDEX.md はエージェント定義ではなく、ディレクトリの索引ファイルです。しかし glob("*.md") は拡張子しか見ないため、INDEX という名前が「定義済みエージェント」として集計に入ります。結果、0-callリストに常に INDEX が出ます。実害はないものの、リストを見るたびに「これは何だっけ」と考えるコストがかかります。対策はINDEXファイルをサブディレクトリへ移すか、スクリプト側に除外リストを持つか、あるいは最初からそういうファイルを置かないことです。現状はそのままにして、「INDEXは常に0回で正常」と頭に入れています。

Exploregeneral-purpose は 0-callリストに出ない理由がわかりにくい。

集計の0-callリストに出るのは「~/.claude/agents/ にファイルが存在するが呼ばれていないもの」だけです。Exploregeneral-purpose はClaude Code組み込みのエージェントであり、ローカルに .md ファイルがありません。そのため known_agents に含まれず、0-callリストには出ません。これは仕様通りですが、最初は「なぜ Explore は出ないのか」と混乱しました。集計のTop 10に組み込みエージェントが上位を占める構造になっていることを把握した上でリストを読む必要があります。

stop hookが走らないケースがある。

stop hookはセッション正常終了時に呼ばれます。Ctrl+Cによる強制終了、プロセスのkill、Claude Codeのクラッシュでは発火しません。そのため「あの長いセッションが記録されていない」という事態が起きます。682件のうち何件が欠損しているかは確認できませんが、「記録がある分だけ分析できる」という前提で運用しています。完璧な記録を求めると運用が止まります。

pending ステータスのレコードが集計に混入する。

status: "pending" のレコードはセッション途中で記録されたものです。集計スクリプトはstatusでフィルタせず全件をカウントします。つまり「開始したが完了したか不明」の呼び出しも呼び出し数に含まれます。実際の682件の内訳を確認するとpendingはごく少数ですが、精度を上げたい場合は "status" != "pending" の条件を追加する必要があります。現状は「多少の誤差は許容する」運用です。

エラー率の解釈を間違える。

Top 10の errors 列はゼロが並んでいますが、これはエラーが一切発生していないという意味ではありません。errors としてカウントされるのは status: "error" のレコードのみです。エージェントが呼ばれた結果「回答が不完全だった」「ファイルを見つけられなかった」という結果はすべて ok として記録されます。「エラー数が少ない=健全に動いている」は言いすぎで、「記録レベルの致命的エラーは少ない」と読むべきです。

複数プロジェクトにまたがると cwd が混在する。

JSONLの各レコードには cwd が記録されています。私のログには /dev/affiliate-fc2/dev/note-autolike/dev/... が混在しています。現在の集計スクリプトはプロジェクトを区別せず全件を合算します。「プロジェクトAでは Explore が多用されているが、プロジェクトBでは一切使われていない」という分析をしたい場合は、cwd フィールドでフィルタするオプションを追加する必要があります。今は全社集計だけで十分なので未実装ですが、プロジェクト数が増えたら対応する予定です。

定義ファイルのサイズ感を軽視している。

0-callエージェントを「まあいいか」と放置しがちですが、実際に数値を出すと見方が変わります。現在8ファイルの合計は109,852バイト(約107KB)・1,221行です。内訳で大きいのが code-reviewer.md(323行)、planner.md(221行)、architect.md(220行)。これらが毎リクエストのシステムプロンプトに注入されます。107KBがどの程度の影響を持つかはコンテキストウィンドウ全体の使い方次第ですが、「使っていない定義を消したら回答が速くなった」という体感は実際に得られます。数値化するとやっと腰が上がります。


ベストプラクティス

実際に運用して効果があったもの、逆に失敗してから得た教訓をまとめます。


1. スクリプトを作る前にログ形式を生ファイルで確認する。

grep '"name"' ~/.claude/projects/*/transcript.jsonl | head -5 を最初に叩く。"Task""Agent" か、subagent_type がどの階層にあるかは、ドキュメントではなく実ファイルが正です。この1コマンドが2日間の無駄を防ぎます。

2. 「定義したエージェントが機能している」は実測するまで信じない。

MUST BE USED という文言は、そのエージェントが選ばれたあとの内部ルールです。エージェント外部からの強制呼び出しには効きません。定義直後に agent-usage-summary.sh 7d を実行して、翌週に0回のままなら「呼び出し元を作るか、削除するか」を即決します。

3. 集計ウィンドウは7日と30日の2つを常に並べる。

agent-usage-summary.sh 7d 30d の組み合わせで、「最近使い始めた」「以前は使っていたが最近使っていない」の両方が1コマンドで見えます。30日で1回だけ呼ばれているエージェントは、7日集計では0回になります。この差が「偶然1回使った程度」であることを示します。

4. 削除を怖がらない。アーカイブを使い分ける。

「将来使うかも」という思考パターンが不要なエージェントを温存します。判断基準をシンプルにしてください——「30日ゼロ回かつ呼び出す仕組みが存在しないなら削除、それ以外はアーカイブ」。アーカイブは ~/.claude/agents/archive/ に移動するだけで、必要になれば戻せます。globが直下しか見ないため、移動した瞬間に集計から外れます。

5. 削除後にすぐ集計を回して効果を確認する。

エージェントを削除したら agent-usage-summary.sh 30d で0-callリストが短くなったことを確認します。「削除したはずなのにまだ出ている」ときはファイルが残っているか、別の場所にコピーがあります。変更の前後で数値を比較する習慣が、環境の信頼性を維持します。

6. stop hookの記録はセッション終了後に手動確認できるようにしておく。

tail -5 ~/.claude/logs/agent-invocations.jsonl | python3 -m json.tool でいつでも最新5件を確認できます。「今日のセッションが記録されたか」を確認する習慣が、hookの死活確認になります。1週間記録がなければhookが壊れているサインです。

7. エラーカウントが突然増えたら優先的に調査する。

平常時はエラー数がゼロです。Top 10テーブルで errors 列に数字が出たら、そのエージェントが問題を起こしています。grep '"status":"error"' ~/.claude/logs/agent-invocations.jsonl で該当レコードを抽出し、session_id からtranscriptを追って何が起きたかを調べます。エラー率の監視は使用率の監視と同じくらい重要です。

8. pending ステータスは別集計して欠損率を把握する。

定期的に grep '"status":"pending"' ~/.claude/logs/agent-invocations.jsonl | wc -l を確認します。682件中のpending数が増加傾向にある場合、強制終了が多い(=セッションが不安定)か、hookの処理が途中で落ちているかのどちらかです。欠損率10%未満なら許容、それ以上なら原因調査に動きます。

9. cron定義では HOMEPATH を明示する。

0 9 * * 1 HOME=/Users/自分/ PATH=/usr/local/bin:/usr/bin:/bin bash ~/claude/scripts/agent-usage-summary.sh 7d 30d >> ~/agent-weekly.log 2>&1

HOME$HOME の展開ができないため値をハードコードします。PATHpython3 が見える場所まで含めます。出力を >> ~/agent-weekly.log にリダイレクトしておくと、失敗したときに理由がわかります。

10. 集計を週次レポートとして記録に残す。

agent-usage-summary.sh 7d 30d >> ~/.claude/logs/agent-weekly-report.log を週次cronに組み込み、ログを蓄積します。「2ヶ月前は python-reviewer が月5回呼ばれていたが今は0回」という変化が、ライブラリの変更や作業内容のシフトを反映していることがあります。時系列で変化を追えるとエージェント設計の振り返りができます。

11. 呼び出しロジックを作る前にエージェントを定義しない。

「定義してから呼び出し元を考える」の順序が問題の根本です。エージェントを定義するときは、同時に「これをいつ・どうやって呼ぶか」を決めます。hook、プロンプトテンプレート、特定コマンド後の自動実行——いずれかが存在しない限り、定義ファイルはシステムプロンプトを肥大化させるだけです。

12. 既存エージェントの呼び出し数を確認してから新しいエージェントを追加する。

追加前に agent-usage-summary.sh 30d を実行し、0-callリストが長ければまず整理します。「新しいエージェントを追加したら古いものが使われなくなった」ではなく、「使われているものだけ存在する」環境を保ちます。定義ファイルの総量が小さいほど、Claude Codeがエージェントを選ぶ判断も明快になります。

13. duration_msを使って重いエージェントを把握する。

Explore の平均実行時間は236ms、general-purpose は3,407ms〜6,830msです。重いエージェントが頻繁に呼ばれているなら、その用途を Explore で代替できないか検討する価値があります。duration_msはJSONLに記録されているため、python3 -c "import json,statistics; data=[json.loads(l) for l in open('~/.claude/logs/agent-invocations.jsonl')]; ..." で平均・中央値を出せます。


まとめ

「定義した=機能している」という思い込みは、Claude Code環境を育てる上で最も静かなコストを生みます。

今回の実測では、8エージェントの定義のうち30日間で呼ばれたのは1種類・1回だけでした。残り7つは合計1,100行・約95KBの定義が毎リクエストのシステムプロンプトを占有し続けていました。これを可視化したのが2本のスクリプトです——stop_agent_tracker.sh がエージェント呼び出しをJSONLへ記録し、agent-usage-summary.sh が期間・エージェント別に集計して0-callリストを出力します。

仕組みのポイントを3つに絞ります。

transcript.jsonlの2パス処理。 tool_useとtool_resultは別行に記録されています。両者を突き合わせて初めて「何を・いつ・成否は」が揃います。1パスで書くと片方しか取れず、記録が不完全になります。

Bash外殻+Python内核の分業。 引数処理と環境変数の受け渡しはBashに任せ、JSONLのパースと集計はPythonに任せます。どちらか一方だけで書こうとすると、得意でない処理で詰まります。

~/.claude/agents/*.md との突き合わせ。 集計が出す0-callリストは、ログだけでは作れません。「定義してあるが呼ばれていない」を検出するには、ファイルシステム側の情報が必要です。globでファイル名を取得してSetで差分を取る1段階が、このスクリプトの核心です。

削除は怖くありません。アーカイブへ移動するだけで集計から外れ、必要になれば戻せます。「数値がなければ判断できない、数値があれば迷わない」——これが自律環境を育てるときの基本姿勢です。

月商120万を支えているのはAIの賢さではなく、「動いているか測れる環境」です。測れるから削れる。削れるからAIが本来の作業に集中できる。そのサイクルを2本のスクリプトで回しています。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


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

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