One line in the Claude Code 2.1.312 changelog made me stop scrolling: Claude had been overwriting very large CLAUDE.md files, rules files and @-included files after seeing only the start of them. I could not say, off the top of my head, how many characters my own CLAUDE.md had.
As an indie developer, I keep the operating rules for my apps and websites in one CLAUDE.md, and I've watched it grow past what I can hold in my head. Then you ask Claude to "tidy it up." It is a convenient request, but if the reader saw only the first half, the second half may be treated as if it never existed.
Here is the short version first: from 2.1.312 on, Write asks Claude to read the whole file or edit it in place. Machines that have not been updated, or a teammate's checkout, can still hit the old behavior. So below I write down how to notice it, how to restore, and how to stop the next one.
A caveat: the Claude Code behavior comes from the changelog wording, and I did not try to reproduce the bug itself. What I did test is the receiving side. I built a 12-chapter, 8,552-character CLAUDE.md for testing, simulated the "back half is gone" state, and ran the scripts below against it.
Why only the back half disappears
The mechanism is simple. Large instruction files can be handed to Claude as just their first portion. I wrote about the character limit on subfolder CLAUDE.md files in When Only the Back Half of a Subfolder CLAUDE.md Is Ignored, Count Characters, Not Bytes.
If Claude sees only the start and decides to "rewrite this file" with Write, the unseen back half is simply not in the new content. It is a replacement rather than an edit, so nothing warns you that something vanished.
The changelog names CLAUDE.md, rules files, and @-included files in subdirectories. I would assume the same symptom can show up elsewhere for other reasons.
Step 1: Check your version and the size of your instruction files
Start with the version. If it is older than 2.1.312, a safety net is worth having.
claude --versionThen list instruction files, largest first. For Japanese text, wc -c counts bytes and overstates the length two to three times, so use wc -m with a fixed locale.
# Problem solved: know which instruction files are big enough to be read only partially
find . -type f \( -name CLAUDE.md -o -path '*/.claude/rules/*.md' \) \
-not -path '*/node_modules/*' | while read -r f; do
printf '%7s chars %s\n' "$(LC_ALL=C.UTF-8 wc -m < "$f")" "$f"
done | sort -rn | headOn my test file the output looked like this (the same file is 22,552 with wc -c):
8552 chars ./sub/CLAUDE.md
8552 chars ./sub/.claude/rules/a.md
8552 chars ./CLAUDE.mdLC_ALL=C.UTF-8 matters because under the C locale wc -m also counts bytes.
Step 2: Check for loss by comparing headings
When something feels off, look at the shape of the git diff first. A file whose back half was cut by a replacement shows almost no additions and a large number of deletions.
git diff --numstat
# 1 138 CLAUDE.md <- 1 line added, 138 deletedOne line added, 138 deleted: that shape says "cut off," not "tidied." Before reading any content, let a script list the headings that disappeared.
#!/usr/bin/env bash
# lost_headings.sh - print headings present in a base revision but missing now
# Usage: lost_headings.sh CLAUDE.md [base revision, default HEAD]
set -euo pipefail
f="${1:?file}"; rev="${2:-HEAD}"
old="$(mktemp)"; trap 'rm -f "$old"' EXIT
git show "${rev}:${f}" > "$old"
echo "lines: $(wc -l < "$old") -> $(wc -l < "$f")"
echo "chars: $(LC_ALL=C.UTF-8 wc -m < "$old") -> $(LC_ALL=C.UTF-8 wc -m < "$f")"
echo "--- missing headings ---"
diff <(grep -E '^#{1,4} ' "$old") <(grep -E '^#{1,4} ' "$f") | grep '^<' | sed 's/^< //' || trueRun against my simulated cut-off state:
lines: 206 -> 68
chars: 8552 -> 2850
--- missing headings ---
## 第5章 ルール5
## 第6章 ルール6
(...omitted...)
## 第12章 ルール12Headings are a good yardstick because they separate rewording from loss. If Claude only polished the wording, the headings survive. If whole headings are gone, the text was not polished; it was dropped.
Step 3: Restore
If the diff is only the cut-off, restoring the committed version is the shortest path.
git restore CLAUDE.mdIf your own uncommitted edits were in the same file, git restore will discard them too. In that case, pull the old version out under another name and paste back only the missing chapters.
git show HEAD:CLAUDE.md > /tmp/CLAUDE.old.md
# open /tmp/CLAUDE.old.md and copy the missing chapters back into the current fileFor a folder not under git, the version history of Dropbox or iCloud is the last resort. But digging through history takes time. Put instruction files in git—that is the one thing worth deciding up front.
Step 4: Stop it from happening again
Three measures, in order of payoff.
First, commit before you ask. A diff exists only if there is a baseline, and one commit before "please tidy this up" is the cheapest insurance.
Second, change how you ask. Add one line: "Fix only this section with Edit; do not rewrite the whole file." Edit replaces in place and does not touch what Claude has not read.
Third, a hook on Write. When a Write would replace an instruction file in full and drop headings or shrink it sharply, the hook sends it back to Claude.
#!/usr/bin/env python3
# .claude/hooks/guard_instruction_rewrite.py
# Problem solved: stop a full-file Write from dropping the back half of an instruction file
import json, os, re, sys
WATCHED = re.compile(r"(^|/)(CLAUDE(\.local)?\.md|\.claude/rules/.+\.md)$")
MIN_CHARS = 6000 # smaller files pass through
SHRINK_LIMIT = 0.7 # block if the new content is under 70% of the old
def headings(text):
return [l.strip() for l in text.splitlines() if re.match(r"#{1,4} ", l)]
def main():
# read stdin as bytes and force UTF-8 (avoids cp932 problems on Windows)
data = json.loads(sys.stdin.buffer.read().decode("utf-8"))
if data.get("tool_name") != "Write":
return 0
inp = data.get("tool_input", {})
path = inp.get("file_path", "")
if not WATCHED.search(path.replace("\\", "/")) or not os.path.isfile(path):
return 0
old = open(path, encoding="utf-8", errors="replace").read()
new = inp.get("content", "")
if len(old) < MIN_CHARS:
return 0
lost = [h for h in headings(old) if h not in set(headings(new))]
ratio = len(new) / len(old)
if lost or ratio < SHRINK_LIMIT:
print(f"[guard] {path}: {len(old)} chars -> {len(new)} chars "
f"({ratio:.0%}), {len(lost)} headings would disappear. "
"Read the whole file again, or use Edit on the section you need.",
file=sys.stderr)
return 2 # exit code 2 blocks the write and returns stderr to Claude
return 0
if __name__ == "__main__":
sys.exit(main())Register it in .claude/settings.json, for Write only.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard_instruction_rewrite.py\""
}
]
}
]
}
}I fed it mock requests on standard input. Results:
| Content being written | Exit code | Why |
|---|---|---|
| Only the first third | 2 (blocked) | Shrinks to 33%, 8 headings vanish |
| Identical content | 0 (allowed) | No change |
| One section appended | 0 (allowed) | No heading lost, no shrinkage |
| One heading line deleted (chapter 5) | 2 (blocked) | Still 100% in size, but a heading is gone |
| A file other than CLAUDE.md | 0 (allowed) | Out of scope |
Blocking on a single deleted heading is deliberate. When I really do want to drop a chapter, I either disable the hook briefly or delete it with Edit.
Pitfalls
The hook guards Write only. A Bash redirect such as cat > CLAUDE.md has a different matcher and walks straight past it. Closing that gap means combining the hook with deny rules.
The other pitfall is a hook that quietly does nothing. I wrote up a hook for Japanese paths that never blocked anything in The Hook That Was Meant to Guard My Japanese Folder Never Blocked Anything. After adding a hook, always trigger it once with a mock input. If you break the settings JSON along the way, When a Broken settings.json Stops Claude Code From Starting covers recovery.
MIN_CHARS and SHRINK_LIMIT are tuned to my test file. Check your own file sizes with Step 1 and adjust.
One thing to do next
A document you have not fully read gets edited only in parts. I want that line drawn both in how I ask and in the tools I put in the way.
Run the find from Step 1 once, check the character count of your largest instruction file, and commit that file. The hook can wait until after that. It is the order I followed in my test setup as well.