DEV Community

Lily
Lily

Posted on • Originally published at dev.to

The Bigger Your CLAUDE.md Gets, the Slower Claude Code Runs — Measuring Context Injection Bytes Weekly to Catch Degradation Early

This is the next installment in my "Claude Code environment" series. In the previous post on the self-audit frustration loop, I wrote about a "never-ending improvement loop." This time I want to dig into what sits at the root of it: detecting and self-correcting context injection bloat.

At the start of every session, Claude Code unconditionally injects the full contents of rules/*.md, ~/.claude/CLAUDE.md, ~/CLAUDE.md, and MEMORY.md. Every time you add a rule, this byte count silently grows. In an audit on 2026-07-11, I found that total injection had exceeded 40KB, and 99 agents I thought I had archived were still being loaded — the main cause of my performance degradation. Since then, I've kept a permanent loop running that measures this automatically every week and attempts self-correction when thresholds are exceeded.

The problem: it gets fat while nobody's looking

Adding rules to CLAUDE.md is a good thing. But "adding" only moves in one direction. After half a year, forgotten sections get duplicated, an archive you thought was compressed turns out to still be live, and files you moved to .agents/ keep getting loaded anyway.

The core issue is that degradation is invisible unless you measure it. Claude's gut feeling of "this seems sluggish" may be correct, but nobody routinely checks how many KB are being injected. I needed a mechanism that produces the numbers weekly and automatically attempts a fix when a human-defined threshold is exceeded.

What to measure

The collect() function in ~/.claude/scripts/cc-self-audit.sh assembles the metrics. There are two axes: static and dynamic measurement.

Static measurement: injection bytes and agent count

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 ' ')
Enter fullscreen mode Exit fullscreen mode

find ... -name '*.md' recurses into dot directories too. Even if you think "I moved them to agents-archive/, so it's fine," if that directory lives under ~/.claude/agents/ as a dot directory, the files keep getting loaded — that's the trap (this is exactly how 99 agents survived on 2026-07-11).

Dynamic measurement: stopspam, frustration words, tool failures

Three metrics are aggregated from the transcripts (.jsonl) since the last run.

STOP_MARKER = 'Stop hook feedback:\\n[~/.claude/hooks/self_audit_stop.sh]: '
FRUST_RE = re.compile(r'何回も言|いい加減にし|嘘つ|舐めんな|なんで治らん|最悪やろ|頭悪い')
Enter fullscreen mode Exit fullscreen mode

stopspam counts only "lines that the Stop hook actually injected into the prompt." A naive grep also picks up the same string when it happens to appear inside a tool_result, inflating the number 6–10x over reality (I fixed exactly this on 2026-07-12, correcting 40 → 6). For frustration, only text blocks of type=user that are not isMeta — i.e., the human's own raw utterances — are counted.

Thresholds

TH_INJECT_BYTES="${SELF_AUDIT_TH_INJECT:-40000}"   # rules+CLAUDE.md+MEMORY.md 合計
TH_AGENTS="${SELF_AUDIT_TH_AGENTS:-60}"            # ~/.claude/agents 配下 .md 総数
TH_STOPSPAM="${SELF_AUDIT_TH_STOPSPAM:-15}"        # 監査hook発火/週
TH_FRUSTRATION="${SELF_AUDIT_TH_FRUST:-8}"         # 不満ワード/週
TH_TOOLERR="${SELF_AUDIT_TH_TOOLERR:-400}"         # tool失敗/週
Enter fullscreen mode Exit fullscreen mode

They're overridable via environment variables, so tuning them later never requires touching the script.

Breach detection → self-repair → independent re-measurement

breaches() lists the items that exceeded their thresholds and hands them to claude -p.

BREACH=$(echo "$METRICS" | breaches)

if [ -z "$BREACH" ]; then
  log "GREEN"
  notify "✅ CC自己監査: 正常 ($METRICS)"
  exit 0
fi
Enter fullscreen mode Exit fullscreen mode

When there are breaches, the prompt restricts fixes to inside ~/.claude only (touching project code, secrets, or deleting plists is explicitly forbidden).

PROMPT="あなたはClaude Code環境の自己監査・自己修復エージェント。週次監査で劣化を検知した。
...
1. ~/.claude 内だけを調査・修正する(プロジェクトコード・secret・plist削除は禁止)
2. 既知の劣化パターンと正典:
   - agents_loaded超過 → ~/.claude/agents/ 配下の退避漏れを ~/.claude/agents-archive/ へ移動
   - inject_bytes超過 → 肥大したrules/MEMORY.mdを圧縮(フル版は ~/.claude/rules-archive/ へ)
   ...
4. 修正後に必ず 'bash ~/.claude/scripts/cc-self-audit.sh --collect-only' を実行し改善を数値で確認
..."
Enter fullscreen mode Exit fullscreen mode

After claude -p finishes, the script itself calls collect() once more for an independent re-measurement. Claude's self-reporting is not trusted.

# 独立再計測(自己申告は信じない)
AFTER=$(collect)
STILL=$(echo "$AFTER" | breaches)

if [ -z "$STILL" ]; then
  notify "🔧 CC自己監査: 劣化検知→自己修正済み。前:[$BREACH] 後:全緑。"
else
  notify "🚨 CC自己監査: 自己修正後も残存 [$STILL]。要確認: $LOG"
fi
Enter fullscreen mode Exit fullscreen mode

When everything is green, it sends ✅ a single line; breach followed by a successful fix sends 🔧 with before/after numbers; breaches remaining after the fix sends 🚨 — all to the #01_alerts channel on Discord. Since it's weekly, it never gets noisy — a low-noise setup that doubles as a liveness check.

Running it via launchd on Sundays at 8:30 AM

Here's the core of ~/Library/LaunchAgents/com.lily.cc-self-audit.plist.

<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key><integer>8</integer>
  <key>Minute</key><integer>30</integer>
  <key>Weekday</key><integer>0</integer>   <!-- 0=日曜 -->
</dict>
<key>Nice</key><integer>10</integer>
<key>LowPriorityIO</key><true/>
<key>ProcessType</key><string>Background</string>
Enter fullscreen mode Exit fullscreen mode

Nice=10 and LowPriorityIO=true are there because claude -p can run for up to 1200 seconds (the default value of FIX_TIMEOUT). To avoid squeezing the Mac's foreground work, the job is explicitly treated as background.

Note: If you don't set RunAtLoad: false, the script runs every time you bootstrap the plist. Don't forget to set RunAtLoad to false for weekly jobs.

Integration with automation-health.sh

The "weekly/monthly batch liveness" section of ~/.claude/scripts/automation-health.sh watches the modification time of cc-self-audit.log.

declare -a CRON_JOBS=(
  "weekly cleanup-misc:$CLAUDE/logs/cleanup-misc.log:192"
  "weekly env-audit:$CLAUDE/logs/env-audit-latest.md:192"
  ...
)
for entry in "${CRON_JOBS[@]}"; do
  a=$(age_h "$path")
  if [ "$a" -le "$budget" ]; then ok "$name: ${a}h前 (budget ${budget}h)"
  else wn "$name: ${a}h前 (budget ${budget}h 超過 — launchd 配送失敗の可能性)"; fi
done
Enter fullscreen mode Exit fullscreen mode

If cc-self-audit.sh didn't run in a given week, automation-health.sh emits a WARN saying "over 192h." This closes the loop of "monitoring the monitor."

Pitfalls I hit

  • Archiving into a dot directory achieves nothing — even with a dot-prefixed name like .agents-backup/, find -name '*.md' still picks it up. You either need to place agents-archive/ outside ~/.claude/, or specify an exclusion path in the agents_loaded measurement expression
  • False grep matches inflated dynamic metrics 10x — transcripts embed "the source of that very script" as tool_result content. Use an exact match on STOP_MARKER plus a python3 JSON parse to filter down to user blocks that are not isMeta
  • Treating self-reporting as completion — even when claude -p says "I compressed inject_bytes," the numbers can't be confirmed without an independent re-measurement via collect(). Always fetch the post-fix score with --collect-only and record it in HISTORY
  • Setting FIX_TIMEOUT too short interrupts the fix — the default 1200 seconds (20 minutes) was almost always enough, but when there's a lot to fix, it times out midway and STILL remains non-empty. In that case, the 🚨 notification hands the decision to a human
  • launchd can't find claude without an explicit PATH — put both /opt/homebrew/bin and ~/.local/bin in the plist's EnvironmentVariables. launchd's PATH is a different beast from your interactive shell's

Summary

  • Claude Code's injected byte count can be measured with find ... | wc -c. Degradation you don't measure is degradation you can't see
  • Start from thresholds of TH_INJECT_BYTES=40000 / TH_AGENTS=60, then tune via env overrides to fit your own environment
  • Delegate fixes to claude -p scoped to ~/.claude only, then independently re-measure immediately afterward to verify its self-reporting
  • The launchd job at 8:30 AM on Sundays plus automation-health.sh gives you double-layered monitoring that can detect "the monitoring itself is dead"
  • Once a week, one line of output — no notification fatigue. A streak of green becomes the proof of health

Next time, I plan to write about how to compress the "bloated MEMORY.md" this loop detects while keeping the index consistent throughout.


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)