[DISCUSSION] Power User Patterns: Hooks, Memory Lifecycle, and Compaction Quality

Resolved 💬 2 comments Opened Mar 9, 2026 by cf770413-dev Closed Mar 16, 2026

Claude Code Power User Patterns: Hooks, Memory Lifecycle, and Compaction Quality

Sharing patterns from ~5 weeks of iterative Claude Code configuration. These address specific pain points I've seen discussed in issues and the community — particularly around post-compaction degradation, memory graph lifecycle, and hook architecture at scale.

Environment

  • Claude Code 2.1.71 on WSL2 Ubuntu
  • Opus 4.6 primary, Sonnet/Haiku for subagents (routing table)
  • 12 custom skills, 30 hook handlers across 13 events, 9 MCP servers, 8 cron jobs
  • Single operator (not a team setup)

---

1. Compaction Quality Pipeline

Problem: Post-compaction performance degrades 30-50% (widely reported). CLAUDE.md instructions are re-injected with a "may or may not be relevant" disclaimer, demoting rules to suggestions. Claude loses track of what it was doing.

Solution: Two-stage pipeline that builds structured state incrementally and injects it into the compaction prompt.

Stage 1: Stop Hook — State Accumulator

A Stop hook fires after every Claude response. It extracts key signals from the last response and appends them to a session-scoped state file:

# ~/.claude/hooks/stop-state-accumulator.sh (async, <5s)
# Extracts from last assistant response:
#   - Files modified (Edit/Write tool uses)
#   - Commands run (Bash tool uses, truncated to 80 chars)
#   - Memory graph mutations (mcp__memory__* calls)
#   - User prompt that triggered the response
#   - Tool use summary (e.g., "Read×3, Edit×2, Bash×1")

# Writes to: ~/.claude/compaction-state/${session_id}.md
# Format: markdown with ### HH:MM headers per response
# Auto-prunes old sessions (keeps last 3)

This builds a timeline of what actually happened, not what Claude remembers happening.

Stage 2: PreCompact Hook — State Injection

When compaction triggers, a PreCompact hook reads the accumulated state file and injects it as a systemMessage:

# ~/.claude/hooks/pre-compact-save.sh
# Reads: ~/.claude/compaction-state/${session_id}.md
# Injects via: {"systemMessage": "Session state for compaction: ..."}
# Capped at 2000 chars to stay within compaction budget

Why It Works

The compaction model gets both the transcript AND a structured timeline of mutations. Even if it summarises poorly, the state file preserves:

  • Which files were modified (recovery)
  • What the user asked for (intent)
  • What memory graph changes were made (persistence audit)
  • The continuation vector (what to do next)

CLAUDE.md Complement

## Compact Instructions
When compacting, preserve: current task objective, modified file list with
paths, failing test output, uncommitted changes, memory graph observations
queued for persistence, and the continuation vector (what to do next). Discard
verbose tool output, dead-end exploration, and intermediate reasoning.

---

2. Memory Graph Lifecycle Management

Problem: The MCP memory server (@modelcontextprotocol/server-memory) stores observations indefinitely. Over time, the graph bloats with stale session-specific data, expired decisions, and superseded findings. No built-in lifecycle management exists.

Solution: A decay class taxonomy with automated TTL eviction.

Decay Classes

Observations are classified by prefix into three decay tiers:

| Class | Default TTL | Examples |
|---|---|---|
| session | 30 days | Per-session quality notes, style metrics, research citations |
| operational | 90 days | Outcomes, scan results, gap analyses, any dated observation without a permanent prefix |
| permanent | never | Reusable patterns, user preferences, terminology, corrections |

The prefix convention (e.g., [PATTERN], [PREFER], [CORRECTION]) lets the eviction script classify observations without parsing their content.

Review-By Override

Any observation can override its class TTL with a :Nm suffix:

[2026-03-09:3m] Chose FastAPI over Flask — review in 3 months

The suffix means "review by N months from observation date." Useful for decisions with a natural shelf life (tech choices: 3m, architecture: 6m, standards: 12m).

TTL Eviction Script

A SessionEnd hook triggers eviction once per day (fire-and-forget background process):

# Key design decisions in ttl-eviction.sh:
# - Reads memory.json as NDJSON (line-by-line entity processing)
# - Backs up to ~/.claude/backups/ before modification (keeps 7)
# - Uses flock for concurrent access safety
# - Atomic replace via tmp file + mv
# - jq parse failures pass through unchanged (no data loss)
# - Writes audit trail to ~/.claude/audit/memory-ttl.log
# - Sentinel file prevents multiple runs per day

Health Check

A monthly cron job reads memory.json directly and alerts if:

  • Entity count > 100
  • Observation count > 500
  • Any single entity > 40 observations
  • More than 10 potentially stale observations (>90 days, no review suffix)

Alert files are surfaced by the session-start protocol.

---

3. Hook Architecture at Scale

Running 30 hook handlers across 13 event types. Key patterns that emerged:

Statusline as Shared State Bridge

Hooks don't receive model or effort level in their input JSON (model appears on SessionStart only). The statusline command runs on every render and has access to full session state. Solution: statusline writes shared state to /tmp/ files that hooks read:

# In statusline-command.sh:
echo "${pct:-0}" > /tmp/claude-context-pct    # for compaction guard
echo "${mshort:-unknown}" > /tmp/claude-model  # for cost tracker
echo "${CLAUDE_CODE_EFFORT_LEVEL:-default}" > /tmp/claude-effort  # for cost tracker

A UserPromptSubmit hook reads /tmp/claude-context-pct to warn when context is high — the hook event itself doesn't carry context window data.

Guard Layering

Three layers, each with a specific role:

  1. permissions.deny — fastest evaluation, zero latency, cannot be bypassed by hook crash. Use for credential/config path protection.
  2. PreToolUse hooks — pattern-matched guards (destructive bash, sensitive fetch, email send, browser evaluate, SSRF prevention). Return JSON with permissionDecision field.
  3. Sandbox — filesystem write scoping + network domain allowlist + excludedCommands. Defence in depth.

The key insight: permissions.deny evaluates before hooks fire. If a hook script crashes or times out, deny rules still protect. Put absolute constraints in deny, conditional checks in hooks.

Permission Model

With autoAllowBashIfSandboxed: true, sandboxed commands are auto-allowed regardless of the permissions.allow list. I initially used 36 explicit Bash(pattern) rules in allow, thinking it added granularity — but it actually caused more permission prompts for commands that didn't match any pattern but were perfectly safe within the sandbox.

Switched to Bash(*) in allow and rely on the sandbox (excludedCommands, filesystem write scoping, domain allowlist) for containment. The deny list and PreToolUse guards handle the exceptions. Approval prompts dropped from ~15/session to ~2/session (only MCP servers that access external APIs now prompt).

PostToolUse Cost Tracker

An async PostToolUse hook (no matcher = fires on every tool call) logs usage for analysis:

# Format: ISO8601|session_id|tool_name|project|model|effort
# Model and effort read from statusline shared state
# Async + 3s timeout = zero interactive latency impact

A weekly cron digest summarises by project, model, effort, and top tools. An on-demand analysis script correlates effort level with session duration and tool call count.

Injection Scanner

A PostToolUse hook scans tool output for prompt injection patterns across 9 attack categories (instruction override, jailbreak, delimiter fraud, context manipulation, etc.). Coverage includes all file reads, web fetches, and MCP tool responses. Fires on 60+ regex patterns in a single grep pass. Non-blocking — logs to audit file and surfaces a systemMessage warning.

---

4. SubagentStart Context Injection

Problem: Subagents don't inherit the parent session's classification constraints or project context.

Solution: A SubagentStart hook reads the project's sensitivity level from a config file and injects constraints via the additionalContext field:

# Pseudocode for subagent-start-inject.sh:
# 1. Match cwd against a project → classification config file
# 2. If "sensitive" → inject "No external model routing. Memory storage limited."
# 3. If "restricted" → inject "No external model routing. Memory storage minimal."
# 4. Always inject project name for context

This is useful for anyone working across projects with different data sensitivity levels — the hook ensures subagents respect the same boundaries as the parent session.

Paired with a SubagentStop hook that checks the subagent's output quality (length, completeness markers) and rejects empty or incomplete results.

---

5. Instruction Design for Claude 4.6

Findings from iterating on CLAUDE.md content over ~50 sessions:

  • Positive framing outperforms negative. "Prefer X over Y" works better than "never do Y." Claude 4.6 overtriggers on NEVER/MUST/CRITICAL — positive framing reduces false constraint activations.
  • ~150-200 usable instruction slots. Beyond this, rules start getting ignored. Prune aggressively.
  • Critical rules at TOP and END of CLAUDE.md — subjectively ~40% better adherence vs middle placement (primacy/recency effect, survives compaction).
  • <xml_tag> wrappers are more effective than prose instructions for the same directive. The model treats tagged content as higher-priority.
  • Effort calibration needs explicit instruction. "Medium for routine, high for multi-step reasoning, max for security reviews" — without this, Claude defaults to high for everything on Opus 4.6.

---

6. Security Posture

Assessed against the OWASP MCP Top 10 (2025 beta): 7 PASS, 2 PARTIAL (accepted risk), 1 N/A. Key controls: secret scanning on memory writes, injection scanning on all MCP tool responses, version pinning on all MCP servers, sandbox with command exclusions and domain allowlist, audit logging on all mutations. The two PARTIAL items (intent flow subversion and cross-session memory sharing) are architectural limits — semantic intent verification is cost-prohibitive per tool call, and the memory server doesn't support session isolation.

---

Metrics

After 5 weeks of iteration:

  • Memory graph: 70 entities, ~580 observations (maintained via TTL eviction + periodic pruning)
  • Hook latency: all <5s, most <200ms. Async hooks add zero interactive latency.
  • Post-compaction recovery: measurably better with the state accumulator pipeline — continuation vector and file list survive consistently.
  • Permission prompts: ~2/session (only external API-accessing MCP servers).

---

What I'd Like to See

  1. Hook input schema expansion — model name, effort level, and context window % in hook input JSON for all events (not just SessionStart) would eliminate the statusline shared state workaround.
  2. Memory server lifecycle hooks — built-in TTL/decay support in @modelcontextprotocol/server-memory rather than external eviction scripts.
  3. Observation prefix conventions — if more users adopt structured prefixes for memory observations, the MCP memory server could support prefix-based queries natively.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗