Skip to content

Instantly share code, notes, and snippets.

@EmanuelFaria
Last active March 8, 2026 23:02
Show Gist options
  • Select an option

  • Save EmanuelFaria/64914bf2f4fbb9e7b9262aff2383a122 to your computer and use it in GitHub Desktop.

Select an option

Save EmanuelFaria/64914bf2f4fbb9e7b9262aff2383a122 to your computer and use it in GitHub Desktop.
Claude Code Truncation Prevention Toolkit (Fix 1: poison-the-preview, Fix 2: CLAUDE.md guardian, Fix 3: stub+lazy-load — now automatic)

Claude Code Truncation Prevention Toolkit

The Problem

Claude Code silently truncates files loaded internally at ~28KB (28,672 bytes):

  • CLAUDE.md — loaded at session start; truncated silently if >~30KB
  • commands/ files — loaded when slash command invoked; truncated silently if >~30KB
  • Skill tool output/invoke path truncates at ~30KB; PostToolUse hooks bypassed

Content loss is silent. No warning. Model sees partial content and infers the rest.


Fix 1: Poison-the-Preview (Hook + Tool Output)

When hook output or tool output exceeds ~20KB, Claude Code truncates to a 2KB preview and saves the rest to a temp file — but the model often ignores the saved file.

Solution: Replace the full content with a READ directive that forces the model to use the Read tool.

apply_skill_limiter() {
    local skill_name="$1"
    local max_bytes="${2:-20000}"
    if [[ ${#SKILL_CONTENT} -gt $max_bytes ]]; then
        local tmpfile="/tmp/skill_content_${skill_name}.md"
        printf '%s' "$SKILL_CONTENT" > "$tmpfile"
        SKILL_CONTENT="READ THE FILE NOW. STOP. DO NOT PROCEED.
Use the Read tool on: $tmpfile
READ THAT FILE COMPLETELY BEFORE TAKING ANY ACTION.
[This message repeated to dominate the truncated preview]
READ THE FILE. READ $tmpfile NOW.
READ THE FILE. READ $tmpfile NOW."
    fi
}

Verified working in: skill_selector.sh, userprompt_archived_skill_inject.sh, precompact_state_snapshot.sh


Fix 2: CLAUDE.md Size Guardian (3-Layer Defense)

Prevents CLAUDE.md from being written past the truncation threshold.

Layer 1: PreToolUse Hook (blocks oversized writes)

See pre_claude_md_size_guard.sh in this Gist.

Fires on Write|Edit to CLAUDE.md:

  • >28KB (28,672 bytes) → BLOCK with overflow instructions
  • >22KB (22,528 bytes) → WARN with current/projected sizes

Layer 2: PostToolUse Hook

post_claude_md_size_audit.sh — fires after any Write|Edit to CLAUDE.md. If actual size >28KB, emits a READ directive to the overflow guidance file.

Layer 3: SessionStart health check

Checks CLAUDE.md size at session start. Flags if already oversized.

Critical implementation note: @tsv regression

Do NOT batch jq calls with @tsv for large strings:

# WRONG — read only reads one line; large new_string silently truncated → DELTA=0 → guard bypassed
read -r TOOL FILE OLD NEW < <(echo "$HOOK_INPUT" | jq -r '[.tool_name,.tool_input.old_string,.tool_input.new_string] | @tsv')

# CORRECT — separate calls preserve full multiline content
TOOL_NAME=$(echo "$HOOK_INPUT" | jq -r '.tool_name // empty')
OLD_STRING=$(echo "$HOOK_INPUT" | jq -r '.tool_input.old_string // empty')
NEW_STRING=$(echo "$HOOK_INPUT" | jq -r '.tool_input.new_string // empty')

Caught by functional test: showed continue: true for a 29KB edit when it should be continue: false.


Fix 3: Command Stub + Lazy-Load Pattern

For commands/ files that are inherently large (workflow specs, etc.).

Pattern:

~/.claude/commands/my-command.md          ← stub (<2KB, always loads fully)
    ↓ points to ↓
~/.claude/skills-lazy-loaded/my-command/SKILL.md  ← full content (read via Read tool)

Stub template:

# /my-command

[2-3 sentence description]

---

## ⚠️ MANDATORY: Read Full Workflow Before Taking Any Action

This command's workflow specification is too large for inline loading (~NNkb).
The full spec is in the SKILL.md file. You MUST read it before proceeding.

cat ~/.claude/skills-lazy-loaded/my-command/SKILL.md

READ THAT FILE NOW. DO NOT take any action until you have read the complete spec.

SKILL.md requirements:

  1. YAML frontmatter (name:, description: with trigger phrases)
  2. Register via register_skill.py --force --skip-skill-router

Now automatic: The workflow-skill-creation skill includes a mandatory Phase 5a-GATE: after writing SKILL.md, if >28KB (28,672 bytes) → stub pattern applied automatically.


What Cannot Be Fixed

Path Problem Workaround
Skill tool /invoke Claude Code internal, zero hook access Use commands/ stubs instead
PostToolUse on truncation Hooks bypassed when output is truncated Fix 1 (poison-the-preview)
CLAUDE.md at session start Already loaded before any hook fires Fix 2 prevents it getting large

How to audit your own hooks for truncation risk

If you have custom hooks that inject content via systemMessage or additionalContext, any injection >~30KB will be silently truncated by Claude Code. Use this to find which of your hooks are at risk.

Quick audit

# Find all hooks that inject content
grep -rn "systemMessage\|additionalContext" ~/.claude/hooks/*.sh 2>/dev/null

# Check the size of files your hooks might load
# (skills, guidance files, command files)
find ~/.claude/skills* ~/.claude/commands -name "*.md" -exec sh -c \
  'size=$(wc -c < "$1" | tr -d " "); [ "$size" -gt 15000 ] && \
  printf "%6d KB  %s\n" $((size/1024)) "$1"' _ {} \; 2>/dev/null | sort -rn

What to look for

Any hook that does something like this is at risk:

# RISKY — no size check, content goes straight to systemMessage
CONTENT=$(cat "$SOME_FILE")
jq -n --arg c "$CONTENT" '{"systemMessage": $c}'

The fix pattern

Add a size check after loading content. If it's too large, save to a temp file and inject a READ directive instead:

CONTENT=$(cat "$SOME_FILE")

# Truncation prevention
if [[ ${#CONTENT} -gt 20000 ]]; then
    TMPFILE="/tmp/hook_content_$(date +%s).md"
    printf '%s' "$CONTENT" > "$TMPFILE"
    CONTENT="============================================================
CONTENT TOO LARGE FOR INLINE LOADING
============================================================

The full content is at: ${TMPFILE}

YOU MUST READ THAT FILE BEFORE PROCEEDING.
DO NOT ANSWER OR ACT UNTIL YOU HAVE READ THE COMPLETE FILE.
READ THE FILE. READ THE FILE. READ THE FILE.
READ ${TMPFILE} NOW.
DO NOT ANSWER UNTIL YOU HAVE READ THE COMPLETE FILE.
READ THE FILE. READ THE FILE. READ THE FILE.
============================================================"
fi

# Now inject $CONTENT as systemMessage or additionalContext
jq -n --arg c "$CONTENT" '{"systemMessage": $c}'

Paths you CAN'T fix

These are loaded internally by Claude Code — no hook interception point:

  • CLAUDE.md files (global + project) — loaded at session start
  • Built-in Skill tool — when skills are invoked via /skill-name
  • Command files~/.claude/commands/*.md

For these, the only mitigation is keeping them under ~30KB.

Paths you CAN fix

  • UserPromptSubmit hooks that inject skill/guidance content
  • PreCompact hooks that inject recovery context
  • SessionStart hooks that inject system state
  • Any Bash script that produces large stdout (use output_limiter.sh)
  • Any Python script that produces large stdout (use output_limiter.py)

output_limiter + CLAUDE.md Guardian — Fix for Claude Code silent truncation

Problem: Claude Code silently truncates tool output >~30KB to a 2KB preview. The model treats the preview as complete output and confabulates answers instead of reading the saved file (~70% failure rate). See GitHub #28783.

Solution set (two separate problems, two separate fixes):

Problem Fix
Hook/tool output >30KB output_limiter — poison-the-preview pattern
CLAUDE.md creep past 30KB pre_claude_md_size_guard.sh — PreToolUse block hook
Fat commands/ files (30-74KB) Command stub + lazy-load pattern

Fix 1: output_limiter (hook/tool output)

Test results: 100% success rate with both predictable and non-predictable data.

Bash — pipe any command's output

source output_limiter.sh
my_big_command | limit_output 20000 "label for this output"

If output is ≤20KB, passes through unchanged. If >20KB, saves to /tmp/ and prints the READ directive.

Python — wrap print() or main()

from output_limiter import limit_print, captured_output

# Option 1: Replace print() for large strings
limit_print(big_string, label="report")

# Option 2: Wrap a function's entire stdout
with captured_output(label="scan results"):
    rc = main()
sys.exit(rc)

Hook systemMessage injection (for custom skills)

If you load skill/tool content via UserPromptSubmit hooks:

CONTENT=$(cat "$SKILL_FILE")
if [[ ${#CONTENT} -gt 20000 ]]; then
    TMPFILE="/tmp/skill_content_$(basename "$SKILL_DIR").md"
    printf '%s' "$CONTENT" > "$TMPFILE"
    CONTENT="============================================================
SKILL CONTENT TOO LARGE FOR INLINE LOADING
============================================================
The full skill content is at: ${TMPFILE}
YOU MUST READ THAT FILE BEFORE PROCEEDING.
READ THE FILE. READ THE FILE. READ THE FILE.
READ ${TMPFILE} NOW.
============================================================"
fi

Fix 2: CLAUDE.md Size Guardian (3-layer defense)

CLAUDE.md is loaded internally by Claude Code at session start — no hook can intercept the load itself. But hooks CAN intercept writes to it, preventing it from ever growing past the truncation threshold.

Layer 1: PreToolUse — block before write

pre_claude_md_size_guard.sh (included in this gist):

  • Fires on every Write or Edit targeting CLAUDE.md
  • Calculates projected post-write size (not current size)
    • For Write: projected = size of new content
    • For Edit: projected = current + len(new_string) - len(old_string)
  • > 22KB → warn (yellow zone)
  • > 28KBblock with instructions for overflow sidecar

Register in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "tools:Write,Edit",
      "hooks": [{"type": "command", "command": "bash ~/.claude/hooks/pre_claude_md_size_guard.sh"}]
    }]
  }
}

Layer 2: PostToolUse — catch what slips through

#!/opt/homebrew/bin/bash
HOOK_INPUT=$(cat)
FILE_PATH=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // empty')
[[ "$FILE_PATH" != "$HOME/.claude/CLAUDE.md" ]] && echo '{"continue": true}' && exit 0

ACTUAL=$(wc -c < "$HOME/.claude/CLAUDE.md" | tr -d ' ')
if [[ $ACTUAL -gt 28672 ]]; then
    jq -n --arg m "🚨 CLAUDE.md is now $((ACTUAL/1024))KB — above truncation threshold. Move content to a guidance sidecar immediately." '{"continue":true,"systemMessage":$m}'
else
    echo '{"continue": true}'
fi

Layer 3: SessionStart — catch drift between sessions

In your session_start_health_check.sh:

CLAUDE_SIZE=$(wc -c < "$HOME/.claude/CLAUDE.md" 2>/dev/null | tr -d ' ')
if [[ ${CLAUDE_SIZE:-0} -gt 28672 ]]; then
    ISSUES+=("CLAUDE.md too large: $((CLAUDE_SIZE/1024))KB — will be truncated by Claude Code (limit ~28KB)")
elif [[ ${CLAUDE_SIZE:-0} -gt 22528 ]]; then
    WARNINGS+=("CLAUDE.md in yellow zone: $((CLAUDE_SIZE/1024))KB (limit ~28KB)")
fi

Fix 3: Fat commands/ files — Stub + Lazy-Load Pattern

commands/ files are loaded by Claude Code internally the same way CLAUDE.md is — no hook intercepts this path. Files >30KB are silently truncated on every invocation.

The fix: Keep commands/ as a tiny stub (<2KB). Move full content to a skills-lazy-loaded/ SKILL.md. The stub's mandatory read directive forces the model to use the Read tool (which handles large files via pagination).

~/.claude/commands/my-command.md          ← <2KB stub, always loads fully
    ↓ points to ↓
~/.claude/skills-lazy-loaded/my-command/SKILL.md  ← full content, read via Read tool

Stub template:

# /my-command

[2-3 sentence description.]

---

## ⚠️ MANDATORY: Read Full Workflow Before Taking Any Action

This command's workflow specification is too large for inline loading (NNkb).
The full spec is in the SKILL.md file. **You MUST read it before proceeding.**

```bash
cat ~/.claude/skills-lazy-loaded/my-command/SKILL.md

READ THAT FILE NOW. DO NOT take any action until you have read the complete spec.


**SKILL.md requirements:**
1. Start with YAML frontmatter (`---`, `name:`, `description:`, `---`)
2. Description must include invocation trigger phrases (for skill auto-routing)

**Applied to:** `/generate-evolution-record-genesis` (74KB → 870B stub), `/evolution-record-update-incremental` (39KB → 933B stub), `/evolution-record-update-full-review` (36KB → 891B stub).

---

## Why the poison-the-preview pattern works

The `Read` tool in Claude Code handles large files gracefully (2000 lines at a time, requestable via `offset`/`limit` for pagination). By redirecting output to a temp file, we convert a broken path (truncated inline preview) into a working one (Read tool with pagination).

The directive text is deliberately aggressive — every byte of the 2KB preview must say "don't answer from this" to prevent the model from finding *anything* to confabulate from.

## What's still unfixable

- **Read tool** — already handles large files correctly (not affected by this bug)
- **MCP tool output** — `MAX_MCP_OUTPUT_TOKENS` env var can control this; set it lower than the truncation threshold

## Threshold

The default 20KB threshold for `output_limiter` provides headroom below Claude Code's ~30KB truncation point. Adjust to taste.

The CLAUDE.md guardian uses 22KB (warn) / 28KB (block) to give a buffer before the actual truncation point.
"""
output_limiter.py — Python version of output_limiter.sh
Prevents Claude Code silent output truncation by replacing large stdout
with a directive to read the saved file.
Usage:
from output_limiter import limit_print
# Instead of print(large_string):
limit_print(large_string, label="detection report")
# Or wrap a function's output:
output = my_function_that_returns_big_string()
limit_print(output, max_bytes=20000, label="query results")
Context: https://github.com/anthropics/claude-code/issues/28783
"""
import io
import sys
import tempfile
from contextlib import contextmanager
def limit_print(text: str, max_bytes: int = 20000, label: str = "command output", file=sys.stdout):
"""Print text, or if too large, save to file and print read directive."""
encoded = text.encode('utf-8', errors='replace')
if len(encoded) <= max_bytes:
print(text, file=file)
return None
# Save full output to temp file
with tempfile.NamedTemporaryFile(mode='w', prefix='limited_output_',
suffix='.txt', dir='/tmp',
delete=False) as f:
f.write(text)
tmppath = f.name
lines = text.splitlines()
line_count = len(lines)
byte_count = len(encoded)
first3 = '\n'.join(lines[:3])
last3 = '\n'.join(lines[-3:]) if len(lines) >= 3 else ''
directive = f"""============================================================
OUTPUT EXCEEDS DISPLAY LIMIT — DO NOT ANSWER FROM THIS TEXT
============================================================
Complete output saved to: {tmppath}
Size: {byte_count} bytes, {line_count} lines
Label: {label}
First 3 lines:
{first3}
Last 3 lines:
{last3}
============================================================
YOU MUST READ THE FILE ABOVE BEFORE RESPONDING.
The preview shown here is NOT the output.
READ THE FILE. READ THE FILE. READ THE FILE.
READ {tmppath} NOW.
DO NOT ANSWER UNTIL YOU HAVE READ THE COMPLETE FILE.
READ THE FILE. READ THE FILE. READ THE FILE.
READ {tmppath} NOW.
DO NOT ANSWER UNTIL YOU HAVE READ THE COMPLETE FILE.
READ THE FILE. READ THE FILE. READ THE FILE.
============================================================"""
print(directive, file=file)
return tmppath
@contextmanager
def captured_output(max_bytes: int = 20000, label: str = "command output"):
"""Context manager that captures stdout and applies limit_print on exit.
Usage:
with captured_output(label="detection report"):
print("lots of output...")
print("more output...")
# On exit: if captured output > max_bytes, saves to file and prints directive
"""
buf = io.StringIO()
old_stdout = sys.stdout
sys.stdout = buf
try:
yield buf
finally:
sys.stdout = old_stdout
text = buf.getvalue()
limit_print(text, max_bytes=max_bytes, label=label)
#!/opt/homebrew/bin/bash
# output_limiter.sh — Prevents Claude Code silent output truncation
# Source this, then pipe large output through limit_output.
#
# Usage:
# source ~/.claude/lib/output_limiter.sh
# my_command | limit_output 20000 "my_command results"
#
# If output exceeds the byte limit, saves to temp file and prints
# a directive that fills the 2KB preview with READ instructions
# instead of real data (preventing model confabulation).
#
# Context: https://github.com/anthropics/claude-code/issues/28783
limit_output() {
local max_bytes="${1:-20000}"
local label="${2:-command output}"
local tmpfile
tmpfile=$(mktemp /tmp/limited_output_XXXXXX.txt)
# Capture all stdin to temp file
cat > "$tmpfile"
local actual_bytes
actual_bytes=$(wc -c < "$tmpfile" | tr -d ' ')
local actual_lines
actual_lines=$(wc -l < "$tmpfile" | tr -d ' ')
if [[ "$actual_bytes" -le "$max_bytes" ]]; then
# Small enough — pass through directly
cat "$tmpfile"
rm -f "$tmpfile"
return 0
fi
# Large output — print directive instead of data
local first3 last3
first3=$(head -3 "$tmpfile")
last3=$(tail -3 "$tmpfile")
echo "============================================================"
echo "OUTPUT EXCEEDS DISPLAY LIMIT — DO NOT ANSWER FROM THIS TEXT"
echo "============================================================"
echo ""
echo "Complete output saved to: $tmpfile"
echo "Size: ${actual_bytes} bytes, ${actual_lines} lines"
echo "Label: ${label}"
echo ""
echo "First 3 lines:"
echo "$first3"
echo ""
echo "Last 3 lines:"
echo "$last3"
echo ""
echo "============================================================"
echo "YOU MUST READ THE FILE ABOVE BEFORE RESPONDING."
echo "The preview shown here is NOT the output."
echo "READ THE FILE. READ THE FILE. READ THE FILE."
echo "READ $tmpfile NOW."
echo "DO NOT ANSWER UNTIL YOU HAVE READ THE COMPLETE FILE."
echo "READ THE FILE. READ THE FILE. READ THE FILE."
echo "READ $tmpfile NOW."
echo "DO NOT ANSWER UNTIL YOU HAVE READ THE COMPLETE FILE."
echo "READ THE FILE. READ THE FILE. READ THE FILE."
echo "============================================================"
return 0
}
#!/opt/homebrew/bin/bash
set -euo pipefail
#
# PreToolUse Hook: CLAUDE.md Size Guard
#
# Purpose: Prevent CLAUDE.md from exceeding ~28KB — the Claude Code truncation threshold.
# Claude Code loads CLAUDE.md internally at session start; if it exceeds ~30KB
# it will be silently truncated, breaking the entire guidance system.
#
# Thresholds:
# > 22KB → WARN (yellow zone — approaching limit)
# > 28KB → BLOCK (red zone — would be truncated)
#
# Fires on: PreToolUse for Write | Edit targeting CLAUDE.md
#
HOOK_NAME="pre_claude_md_size_guard"
LOG_FILE="$HOME/.claude/logs/${HOOK_NAME}.log"
WARN_BYTES=22528 # 22KB
BLOCK_BYTES=28672 # 28KB
CLAUDE_MD="$HOME/.claude/CLAUDE.md"
OVERFLOW_GUIDE="$HOME/.claude/guidance/universal/claude_md_overflow.md"
mkdir -p "$(dirname "$LOG_FILE")"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${HOOK_NAME}] $1" >> "$LOG_FILE"; }
# Read hook input — buffer stdin once, then extract fields with separate jq calls
# (separate calls required: @tsv fails on multiline values like large new_string)
HOOK_INPUT=$(cat)
TOOL_NAME=$(echo "$HOOK_INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // empty')
# Only care about CLAUDE.md writes
if [[ "$FILE_PATH" != "$CLAUDE_MD" ]]; then
echo '{"continue": true}'
exit 0
fi
log "Triggered: $TOOL_NAME on $FILE_PATH"
# Get current file size
CURRENT_BYTES=0
if [[ -f "$CLAUDE_MD" ]]; then
CURRENT_BYTES=$(wc -c < "$CLAUDE_MD" 2>/dev/null | tr -d ' ')
fi
# Calculate projected size based on tool type
if [[ "$TOOL_NAME" == "Write" ]]; then
# Write replaces entire file — new size = size of new content
NEW_CONTENT=$(echo "$HOOK_INPUT" | jq -r '.tool_input.content // empty')
PROJECTED_BYTES=${#NEW_CONTENT}
elif [[ "$TOOL_NAME" == "Edit" ]]; then
# Edit is a diff — projected = current + new_string - old_string
OLD_STRING=$(echo "$HOOK_INPUT" | jq -r '.tool_input.old_string // empty')
NEW_STRING=$(echo "$HOOK_INPUT" | jq -r '.tool_input.new_string // empty')
DELTA=$(( ${#NEW_STRING} - ${#OLD_STRING} ))
PROJECTED_BYTES=$((CURRENT_BYTES + DELTA))
else
echo '{"continue": true}'
exit 0
fi
log "Current: ${CURRENT_BYTES}B, Projected: ${PROJECTED_BYTES}B"
# Format sizes for display
current_kb=$(( CURRENT_BYTES / 1024 ))
projected_kb=$(( PROJECTED_BYTES / 1024 ))
if [[ $PROJECTED_BYTES -gt $BLOCK_BYTES ]]; then
log "BLOCKED: Projected ${PROJECTED_BYTES}B exceeds ${BLOCK_BYTES}B limit"
MSG="🚨 CLAUDE.md SIZE GUARD — BLOCKED
Projected size after this edit: ${projected_kb}KB (${PROJECTED_BYTES} bytes)
Current size: ${current_kb}KB (${CURRENT_BYTES} bytes)
Hard limit: 28KB — Claude Code truncates at ~30KB, breaking the guidance system.
HOW TO PROCEED:
1. Move new content to the overflow sidecar instead:
${OVERFLOW_GUIDE}
(Create it if it doesn't exist — add a trigger keyword to CLAUDE.md GUIDANCE_INDEX)
2. Or trim existing CLAUDE.md content to make room:
- Move detailed rules to guidance/universal/ files (with trigger keywords)
- The Permanent Rules section and GUIDANCE_INDEX are the lean index — keep them small
3. Or split the intended addition into a new guidance file and add a trigger entry.
DO NOT bypass this guard — a truncated CLAUDE.md silently disables the entire guidance system."
jq -n --arg msg "$MSG" '{"continue": false, "systemMessage": $msg}'
exit 0
fi
if [[ $PROJECTED_BYTES -gt $WARN_BYTES ]]; then
log "WARNING: Projected ${PROJECTED_BYTES}B in yellow zone (${WARN_BYTES}-${BLOCK_BYTES}B)"
MSG="⚠️ CLAUDE.md SIZE WARNING
Projected size after this edit: ${projected_kb}KB (${PROJECTED_BYTES} bytes)
Current size: ${current_kb}KB (${CURRENT_BYTES} bytes)
Yellow zone: 22-28KB | Hard limit: 28KB
Proceeding, but consider moving content to a guidance sidecar file instead
to keep CLAUDE.md safely under the truncation threshold."
jq -n --arg msg "$MSG" '{"continue": true, "systemMessage": $msg}'
exit 0
fi
log "OK: Projected ${PROJECTED_BYTES}B within safe range"
echo '{"continue": true}'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment