Claudeが「なんで治らんの」を検知して自分のCLAUDE.mdを直す ― transcript不満シグナル駆動の自己改善ループ
前回、会話ログをスキル使用状況の分析に使う話を書きました。今回はその発展形で、transcriptから「なんで治らんの」「いい加減にし」のような私の苛立ちワードを正規表現で拾い、閾値を超えたらClaude自身が~/.claudeを直しに行く週次ループの話です。
結論を先に書きます。177行のシェルスクリプトが日曜8:30にlaunchdで起動し、①ルール・CLAUDE.md・MEMORY.mdの注入バイト数と②transcript内の不満ワード出現数を実測します。2026-07-12には検知ロジックの粗さが原因で不満ワードが4件→18件に誤爆し、Claudeが「直した」と自己申告した直後の再計測ではむしろ18→32に悪化していました。その後grepの誤検知を潰すコード修正を入れ、07-19以降は0〜1件で安定しています。測定の解像度が粗いと、監査そのものが誤検知を積み増すという話です。
困りごと:不満は言うが、その不満はどこにも残らない
Claude Codeを使っていて「またこれか」「なんで直らないんだろう」と口に出す瞬間があります。でもその苛立ちは会話が終われば流れていくだけで、CLAUDE.mdにもメモリにも残りません。同じ種類のミスを何度も踏んで、その都度その場しのぎで直して、また忘れる ―― というループが起きていました。
一方でtranscriptのJSONLには、私が実際に何と言ったかがそのまま残っています。ここから不満のシグナルだけを抜き出して閾値化すれば、「今週は苛立ちが多かった=環境が劣化している」を機械的に検知できるはずです。これがcc-self-audit.shの動的計測パートの発想です。
全体像:週1・4ステップ・fail-open
~/.claude/scripts/cc-self-audit.sh(177行)がcom.lily.cc-self-auditというlaunchd job で日曜8:30に起動します。冒頭のコメントに設計意図がそのまま書いてあります。
# cc-self-audit.sh — Claude Code 自身の劣化を検知して自己改善する週次ループ
#
# 背景: 2026-07-11パフォーマンス監査で「退避したはずのagents 99体が注入され続ける」等の
# 環境劣化がユーザー体感悪化の主因と判明。以後は人間が面倒を見ずに済むよう常設化。
#
# 動作:
# 1) 静的計測: 注入バイト数(rules/CLAUDE.md/MEMORY.md)・agents読込数(ドットdir再発検知)
# 2) 動的計測: 前回実行以降のtranscriptから 監査hookスパム/不満ワード/tool失敗 を集計
# 3) 閾値超過 → claude -p (sonnet, MAX枠) が ~/.claude 内のみ自己修正
# 4) 独立再計測で改善を検証(自己申告は信じない) → Discord #01_alerts へ報告
set -uo pipefail+fail-open(監査自体が落ちても他を壊さない)が前提で、閾値は環境変数で上書きできます。
TH_INJECT_BYTES="${SELF_AUDIT_TH_INJECT:-40000}" # rules+CLAUDE.md+MEMORY.md 合計
TH_AGENTS="${SELF_AUDIT_TH_AGENTS:-60}" # ~/.claude/agents 配下 .md 総数(再帰=ドットdir再発検知)
TH_STOPSPAM="${SELF_AUDIT_TH_STOPSPAM:-15}" # 監査hook発火/週
TH_FRUSTRATION="${SELF_AUDIT_TH_FRUST:-8}" # 不満ワード/週
TH_TOOLERR="${SELF_AUDIT_TH_TOOLERR:-400}" # tool失敗/週
静的計測:バイト数とagents数はfindとwcだけ
静的パートは素朴です。rules/*.md・CLAUDE.md・MEMORY.mdをcatしてwc -cで足すだけ。
inject_bytes=$(( \
$(find "$HOME/.claude/rules" -name '*.md' -print0 2>/dev/null | xargs -0 cat 2>/dev/null | wc -c) + \
$(cat "$HOME/.claude/CLAUDE.md" 2>/dev/null | wc -c) + \
$(cat "$HOME/CLAUDE.md" 2>/dev/null | wc -c) + \
$(cat "$HOME/.claude/projects/-Users-matsubara/memory/MEMORY.md" 2>/dev/null | wc -c) ))
agents_loaded=$(find "$HOME/.claude/agents" -name '*.md' 2>/dev/null | wc -l | tr -d ' ')
agents_loadedを再帰findにしているのは、「退避したはずのagentsがドットdirにまだ残っていて読み込まれ続ける」という2026-07-11の実際の障害を踏まえた検知です。ここは単純なバイト数・ファイル数のカウントなので、誤検知の余地はほぼありません。問題は次の動的計測でした。
動的計測:transcriptから不満ワードを拾う
history.jsonlによるとinject_bytesとagents_loadedは今のところ安定しています(agents_loadedは48→38→28と減少傾向で、Curatorの退避が効いています)。一方stopspam(監査hookの発火回数)とfrustration(不満ワード出現数)は、動的計測の設計次第で暴れました。
対象は「前回実行以降・100KB超・最大200ファイル」のtranscript JSONLです。
[ -f "$LASTRUN" ] && newer="-newer $LASTRUN"
files=$(find "$HOME/.claude/projects" -maxdepth 2 -name '*.jsonl' $newer -size +100k 2>/dev/null | head -200)
不満ワードの正規表現はこれです。
FRUST_RE = re.compile(r'何回も言|いい加減にし|嘘つ|舐めんな|なんで治らん|最悪やろ|頭悪い')
踏んだ落とし穴:2026-07-12のfalse-positive事故
最初の実装は「JSONLをそのままgrepして
FRUST_REにマッチした行数を数える」という素朴なものでした。これがtranscript特有の落とし穴にはまりました。
history.jsonlの実データがそのまま証拠になっています。
{"ts":"2026-07-11 22:37:42", "stopspam":6, "frustration":5, "toolerr":3}
{"ts":"2026-07-12 08:30:06", "stopspam":40, "frustration":18, "toolerr":67}
1日でstopspamが6→40、frustrationが5→18に跳ねました。原因はコード中のコメントに残っています。
# 素朴なgrepはtranscript内でhookスクリプト自身のソース(old_string/new_stringの引用やcat出力)や
# tool_result本文(Readで偶然その部分文字列を含むファイルを開いただけ)まで誤検知する
# (2026-07-12: stopspam 6→40, frustration 4→18 に見えたが実体はほぼこの誤検知だった)。
transcriptのJSONLには、Claude自身がEditツールで書いたコードや、Readツールがそのまま返したファイル内容も1行として入っています。つまりself_audit_stop.shのソースコードをReadしただけで、そのソース中の文字列がgrepにヒットしていました。私が「なんで治らんの」と本当に言った回数ではなく、その文字列を含むファイルを開いた回数を数えてしまっていたわけです。
修正後の計測ロジックが今のコードです。stopspamはハーネスが実際に注入する固定マーカーの完全一致だけを数え、frustrationはtype=userかつisMetaでないブロック(=人間が実際に打った発言)のテキストだけを対象にします。
STOP_MARKER = 'Stop hook feedback:\n[~/.claude/hooks/self_audit_stop.sh]: '
stopspam = frustration = 0
for path in sys.argv[1:]:
with open(path, encoding='utf-8', errors='ignore') as fh:
for line in fh:
if STOP_MARKER in line:
stopspam += 1
try:
o = json.loads(line)
except Exception:
continue
if o.get('type') != 'user' or o.get('isMeta'):
continue
content = (o.get('message') or {}).get('content')
texts = [content] if isinstance(content, str) else \
[b.get('text', '') for b in content if isinstance(b, dict) and b.get('type') == 'text'] \
if isinstance(content, list) else []
if any(FRUST_RE.search(t) for t in texts):
frustration += 1
stopspamは「JSONL全体をgrep」から「ハーネスが実際に注入するStop hook feedback:行の完全一致」に。frustrationは「全行grep」から「type=userかつisMeta=falseのテキストブロックだけ」に絞りました。効果は次の週の実測にそのまま出ています。
| ts | inject_bytes | agents_loaded | stopspam | frustration | toolerr |
|---|---|---|---|---|---|
| 07-12 08:30 (誤検知直後) | 21966 | 48 | 40 | 18 | 67 |
| 07-19 08:30 (修正後) | 23855 | 48 | 0 | 1 | 28 |
| 07-26 08:30 | 31620 | 48 | 0 | 6 | 10 |
| 08-02 08:30 | 34872 | 48 | 4 | 0 | 58 |
| 08-09 08:30 | 38135 | 38 | 0 | 1 | 86 |
修正後はstopspamがほぼ0、frustrationも0〜6件のレンジに収まっています。
自己申告を信じない:独立再計測が2回とも「盛り」を検出した
このスクリプトの肝は、修正フェーズの後にもう一度同じcollect()を独立実行して、閾値内に収まったかを機械的に確認する点です。Claudeが「直しました」と言った文章は一切信用せず、数値だけを見ます。
log "invoking claude -p (model=$MODEL)"
OUT=$(... "$CLAUDE" ... -p --model "$MODEL" --allowedTools "Read,Write,Edit,Bash,Grep,Glob" --max-turns 50 2>&1) || true
# 独立再計測(自己申告は信じない)
AFTER=$(collect)
STILL=$(echo "$AFTER" | breaches)
if [ -z "$STILL" ]; then
notify "🔧 CC自己監査: 劣化検知→自己修正済み。前:[$BREACH] 後:全緑。"
else
notify "🚨 CC自己監査: 自己修正後も残存 [$STILL]。要確認: $LOG"
fi
この設計が実際に「盛り」を捕まえたケースが2回、ログに残っています。
1回目(07-12):Claudeの出力は「frustration 18→0(誤検知だったtool_result部分一致を排除、実発言は0件)、stopspam 40→27」でした。しかし直後の独立再計測はstopspam:58, frustration:32。自己申告は改善を主張したのに、実測はむしろ悪化していました。原因は同じ計測窓($LASTRUNより新しいファイル)を「前」と「後」でほぼ同じファイル集合に対して再実行していたためで、根本的な誤検知の解消は次の週次サイクル(07-19)までずれ込みました。それでも独立再計測がある分だけ「直った」という誤った報告を鵜呑みにせずに済んでいます。
2回目(08-16):inject_bytesが48958まで増え、閾値40000を超えてRED判定。Claudeを呼びましたが、ログにはこう残っています。
[2026-08-16 08:30:36] invoking claude -p (model=sonnet)
[2026-08-16 08:50:37] claude output:
[2026-08-16 08:51:05] after: {"inject_bytes":48958, ...}
claude output:の後が空です。20分動いて、テキスト出力なし、inject_bytesも48958のまま1バイトも動いていません。独立再計測がSTILLに違反を残したことを検知し、Discordには🚨(成功扱いの🔧ではなく)が飛びました。これはまだ未解決で、次の週次サイクルで手を入れる必要があります。自己修正が実際には何もしなかったケースも、独立再計測という設計のおかげで「静かに失敗」にはならず「赤信号のまま」で報告されています。
なおtoolerr(tool失敗数)だけは今もgrep -c '"is_error":true'の生カウントのままで、frustrationのようなisMeta/type絞り込みはしていません。閾値400に対して実測は最大86なのでまだ問題は顕在化していませんが、同じ穴が理論上は残っています。
踏んだ落とし穴
- transcriptの素朴なgrepはコード引用まで拾う → ハーネスが実際に注入する固定マーカーの完全一致 /
type=userかつisMeta=falseのテキストのみに絞る - 「修正した」という自己申告と実測が食い違う → 修正後に必ず同じ
collect()を独立再実行し、STILLが空でなければ🚨で報告する - claude -pがテキスト出力なしで終わることがある →
2>&1 || trueで握りつぶさず、OUTが空でも独立再計測のbreaches判定だけは必ず信じる - 一部の指標だけ精緻化して他は素朴なまま →
toolerrは今も生grep。解像度の非対称は残存リスクとして自覚しておく agents_loadedの閾値テストをDRYで検証 → 本番閾値ではなくSELF_AUDIT_TH_AGENTS=1のようなテスト値でRED経路を先にDRY実行し、修正フェーズを一度も走らせずに検知ロジックだけ確認できる
まとめ
- 静的計測(バイト数・ファイル数)は誤検知しにくいが、動的計測(transcriptの自然言語)は測り方の解像度がそのまま監査の信頼性になる
- 2026-07-12の事故は「grepが自分のソースコードやtool_result本文まで拾う」という、transcriptならではの自己参照バグだった
- 修正は「ハーネスが注入する固定文字列の完全一致」と「
type=userかつisMeta=falseのテキストだけを見る」という対象範囲の絞り込み - 自己申告(
claude -pの出力文)は信じず、修正後に同じ計測を独立再実行して数値で判定する。この設計が「盛った成功報告」と「静かな失敗」の両方を実際に捕まえた - 一部の指標(
toolerr)はまだ粗いまま残っている。全指標を同じ解像度に揃えるのは今後の課題
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!