Posted on Jul 22 • Originally published at dev.to
This is the next installment in my "Claude Code environment" series. Combined with the idempotent marker implementation from the previous article, I've come to realize that more than half of the reasons an autonomous loop won't stop come down to one thing: environment decay.
A Claude Code setup gets fat if you leave it alone. The rules balloon, agents you thought you'd retired come back to life, and hooks keep misfiring. The only thing you actually notice is a vague "this feels sluggish" or "it keeps making the same mistake," and then you burn time hunting down the cause. This article is about a system that measures that decay every week, lets claude -p fix it, and then confirms the improvement with an independent re-measurement after the fix.
The problem: 99 "retired" agents were still being injected
This is a real incident I hit during a performance audit on 2026-07-11.
I'd tidied up ~/.claude/agents/ several times. I'd manually moved agent definitions I no longer used into agents-archive/ and figured I was done. But when I actually measured, agents_loaded was still stuck at 99. The culprit was dot-directories (.deprecated/, .backup/, and the like). find's default behavior recurses into dot-directories too. They looked deleted, but every single one was still being loaded through the hidden directories.
This real incident is what prompted me to write cc-self-audit.sh.
Five metrics
The script's collect() function grabs five metrics every run.
# ~/.claude/scripts/cc-self-audit.sh(collect関数より)
# --- 静的 ---
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 ' ')
# --- 動的(前回実行以降のtranscriptのみ・最大200ファイル)---
stopspam=$(echo "$files" | xargs /usr/bin/grep -h -c 'セルフ監査未実施。実装' 2>/dev/null | awk '{s+=$1} END{print s+0}')
frustration=$(echo "$files" | xargs /usr/bin/grep -h -c -E '何回も言|いい加減にし|嘘つ|舐めんな|なんで治らん|最悪やろ|頭悪い' 2>/dev/null | awk '{s+=$1} END{print s+0}')
toolerr=$(echo "$files" | xargs /usr/bin/grep -h -o -c '"is_error":true' 2>/dev/null | awk '{s+=$1} END{print s+0}')
Enter fullscreen mode Exit fullscreen mode
| Metric | Meaning | Threshold |
|---|---|---|
inject_bytes |
Total bytes of rules + CLAUDE.md + MEMORY.md | 40,000 |
agents_loaded |
Total .md count under ~/.claude/agents/ (recursion including dot-dirs) |
60 |
stopspam |
Number of times the audit hook fired / week | 15 |
frustration |
Occurrences of frustration words in transcripts / week | 8 |
toolerr |
Count of "is_error":true in transcripts / week |
400 |
The two static metrics are proxy variables for injection cost, and the three dynamic metrics are proxy variables for experience quality. The frustration-word detection may look out of place, but if I'm scolding it repeatedly, that means it's repeating the same failure — and those failures often stem from environment problems (misfiring hooks, rotted memory, broken tools).
The thresholds can be overridden with env variables.
TH_INJECT_BYTES="${SELF_AUDIT_TH_INJECT:-40000}"
TH_AGENTS="${SELF_AUDIT_TH_AGENTS:-60}"
TH_STOPSPAM="${SELF_AUDIT_TH_STOPSPAM:-15}"
TH_FRUSTRATION="${SELF_AUDIT_TH_FRUST:-8}"
TH_TOOLERR="${SELF_AUDIT_TH_TOOLERR:-400}"
Enter fullscreen mode Exit fullscreen mode
When a threshold is exceeded, claude -p fixes itself
If every metric is under its threshold, it just posts a one-line ✅ GREEN to Discord and exits. If any is over, it calls claude -p to fix it.
BREACH=$(echo "$METRICS" | breaches)
if [ -z "$BREACH" ]; then
log "GREEN"
touch "$LASTRUN"
notify "✅ CC自己監査: 正常 ($METRICS)"
exit 0
fi
log "RED: $BREACH"
if [ "$DRY" = "1" ]; then log "DRY=1: 修正スキップ"; touch "$LASTRUN"; exit 0; fi
Enter fullscreen mode Exit fullscreen mode
The prompt passed to claude -p has a remediation policy embedded for each decay pattern.
PROMPT="...
## 指示
1. ~/.claude 内だけを調査・修正する(プロジェクトコード・secret・plist削除は禁止)
2. 既知の劣化パターンと正典:
- agents_loaded超過 → ~/.claude/agents/ 配下の退避漏れ(ドットdirも読み込まれる)を ~/.claude/agents-archive/ へ移動
- inject_bytes超過 → 肥大したrules/MEMORY.mdを圧縮(フル版は ~/.claude/rules-archive/ へ。リンクは全維持し索引整合を検証)
- stopspam超過 → ~/.claude/hooks/self_audit_stop.sh の抑制ロジックを点検
- frustration/toolerr超過 → 該当transcriptをgrepして繰り返し失敗の真因を特定し、hook/スキル/メモリで再発防止を仕込む
3. 変更は1件ずつ ${CHANGELOG} に「日時/対象/理由/戻し方」を追記
4. 修正後に必ず 'bash ~/.claude/scripts/cc-self-audit.sh --collect-only' を実行し改善を数値で確認
..."
Enter fullscreen mode Exit fullscreen mode
I restrict it to filesystem operations only with --allowedTools "Read,Write,Edit,Bash,Grep,Glob", and cap it at max-turns 50 and a 20-minute wall clock (FIX_TIMEOUT=1200).
Note
Having claude run--collect-onlyitself inside the fix loop is strictly "a confirmation as part of the remediation procedure." The final verdict of the audit is made by the outer, independent re-measurement. The key to the design is not taking the inner confirmation at face value.
A "don't trust self-reporting" fail-closed design
Even when claude -p says "fixed it," I don't believe it right away. The outer harness calls collect() again to re-measure the numbers.
# 独立再計測(自己申告は信じない)
AFTER=$(collect)
log "after: $AFTER"
echo "$AFTER" >> "$HISTORY"
STILL=$(echo "$AFTER" | breaches)
touch "$LASTRUN"
if [ -z "$STILL" ]; then
notify "🔧 CC自己監査: 劣化検知→自己修正済み。前:[$BREACH] 後:全緑。詳細=$CHANGELOG"
else
notify "🚨 CC自己監査: 自己修正後も残存 [$STILL]。要確認: $LOG / $CHANGELOG"
fi
Enter fullscreen mode Exit fullscreen mode
If a violation still remains after the fix, it flies off to Discord with a 🚨 and hands it over to a human. Trust only the OS's measured values, not self-reporting — that's the core of fail-closed.
All measured values are stacked one record per line in ~/.claude/self-audit/history.jsonl, and the prompt handed to claude also includes the most recent 5 entries as a trend. By looking at the magnitude of change — "it was 30KB last week but 51KB this week" — I can distinguish a one-off outlier from a continuous increase.
Weekly launch via launchd, Sundays at 8:30
<!-- ~/Library/LaunchAgents/com.lily.cc-self-audit.plist -->
<key>StartCalendarInterval</key><dict>
<key>Weekday</key><integer>0</integer>
<key>Hour</key><integer>8</integer>
<key>Minute</key><integer>30</integer>
</dict>
<key>RunAtLoad</key><false/>
Enter fullscreen mode Exit fullscreen mode
Weekday=0 is Sunday. I set RunAtLoad to false to prevent it from running immediately right after registering with launchd. I include ~/.local/bin in the environment variable PATH and place a claude shim there.
There are two modes for manual runs.
# 計測だけ(claudeを呼ばない)
~/.claude/scripts/cc-self-audit.sh --collect-only
# ドライラン(違反表示のみ・修正なし)
SELF_AUDIT_DRY=1 ~/.claude/scripts/cc-self-audit.sh
Enter fullscreen mode Exit fullscreen mode
Before putting it into production, I always eyeballed the metrics with DRY=1 first, then enabled it.
Pitfalls I hit
-
Dot-dirs get caught by
find→~/.claude/agents/.deprecated/and the like became recursion targets, so agents I thought I'd retired kept getting counted. The root cause of the real incident. -
The transcript
findis slow → Unless you cap the number of files withhead -200and exclude empty files with-size +100k, the first run of the weekend takes over 3 minutes. -
The frustration-word regex mixes in Japanese → Unless you set
LANG=en_US.UTF-8at the top,grep -Emisbehaves on multibyte characters. -
claude -pisn't on the PATH → launchd's minimal PATH was missing~/.local/bin. Solved by explicitly adding it to the plist's EnvironmentVariables. -
Making claude do the post-fix re-measurement internally turns it into self-grading → The outer
collect()call is mandatory. The inner confirmation stays limited to "part of the remediation procedure."
Warning
As the comment says —bash 3.2 compatible, fail-open— the audit script itself is designed so that nothing breaks even if it crashes. It usesset -uo pipefail, each individual measurement swallows errors with2>/dev/null || true, and in the worst case the measured value becomes 0 and gets treated as "GREEN." Having the main system die because of an audit failure would be putting the cart before the horse.
Summary
- A Claude Code setup accumulates decay that, if left alone, can be measured across five metrics: inject_bytes, agent count, hook spam, frustration words, and tool failures
- When a threshold is exceeded,
claude -pfixes only what's inside~/.claude, and records it in the change log (self-audit-changes.log) with a "how to revert" note - After the fix, an outer independent re-measurement verifies the improvement numerically. A self-reported "fixed it" alone is not treated as done
- Weekly launch via launchd, Sundays at 8:30. You can manually check with
--collect-onlyandDRY=1 - The script itself is fail-open (an audit failure won't break the environment), while the target of self-repair is fail-closed (if a violation remains after the fix, it escalates to a human)
Next time, I'll write about how I trimmed down the inject_bytes overage that this audit loop detected — a pipeline that compresses the rules and auto-slims them every week.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.