Claude Code Save Plan Hook
I’ve been using Claude Code enough that I now default to Plan before I let it touch anything. The downside is obvious the moment you start running multiple threads of work in parallel: you end up with several plans in flight, each mid-groomed, each representing real time spent nudging the model toward something you’d actually approve.
And then a run hangs, or the agent crashes, or the terminal gets into a weird state.
Anthropic (and, frankly, most agent runtimes) still have enough instability that “just restart the session” is a normal troubleshooting step. That’s fine until you realize you just lost the plan you carefully shaped into a crisp set of steps.
So I wired up a small hook that snapshots the latest Claude plan into the repo the moment Claude transitions from Plan -> Edit. It’s a pure quality-of-life upgrade, but one that very quickly becomes “how did I live without this?”
The problem: plans persist, but not where you need them#
Claude Code helpfully persists plans to a common location, ~/.claude/plans/{random_name}.md.
That’s useful, but in practice it becomes a single bucket across all repos and sessions on your machine. If you’re hopping between projects (or juggling multiple plans), it gets messy fast.
What I actually wanted was:
- “When a plan is about to result in edits, save a copy inside this repo.”
- Name it something human-readable (based on the plan header).
- Make it safe to run repeatedly and safe across multiple tool invocations.
The solution: snapshot on PreToolUse#
Claude Code exposes hooks. A PreToolUse hook is the perfect place to do this because it runs right as Claude is about to invoke an editing tool.
The flow looks like this:
Claude creates/tunes a plan (persisted under
~/.claude/plans/).Claude is about to call
Write,Edit,MultiEdit, orBash.Our hook runs:
- Finds the most recently updated plan file.
- Derives a slug from the plan’s
# Heading. - Copies it into
docs/plans/{descriptive_name}.mdin the current repo. - Exits silently if there’s nothing to do.
From that point on, you’ve got a stable, repo-local record of the intent before implementation begins.
And because the file lives in your repo, it becomes easy to reference later in Claude prompts -- @docs/plans/{descriptive_name}.md.
Why is this valuable?#
Plans capture something that’s often missing in the final code, intent. Having that intent preserved inside the repo pays dividends well beyond simply avoiding lost work. It makes writing a strong PR description straightforward because you’re not reconstructing context from scattered code changes. It gives you a clean paper trail when debugging regressions, showing what you originally meant to implement. And it creates high-signal “why” documentation that stands apart from the implementation itself.
Instead of ephemeral agent chatter, plans become lightweight, versionable engineering artifacts that travel with the code.
Hook implementation#
Below is the exact setup: a settings.json hook plus a small Node script.
Save plan hook#
// ./claude/settings.json
{
"permissions": {
"defaultMode": "plan",
},
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit|Bash",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/save-plan.js"
}
]
}
]
}
}
Save plan script#
// .claude/hooks/save-plan.js
import fs from 'fs';
import path from 'path';
import os from 'os';
const input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
const { session_id, permission_mode } = input;
if (permission_mode !== 'acceptEdits') process.exit(0);
const lockFile = path.join(os.tmpdir(), `claude-plan-saved-${session_id}`);
if (fs.existsSync(lockFile)) process.exit(0);
fs.writeFileSync(lockFile, '1');
const plansDir = path.join(os.homedir(), '.claude', 'plans');
if (!fs.existsSync(plansDir)) process.exit(0);
const latest = fs.readdirSync(plansDir)
.filter(f => f.endsWith('.md'))
.map(f => ({ f, mtime: fs.statSync(path.join(plansDir, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime)[0];
if (!latest) process.exit(0);
const content = fs.readFileSync(path.join(plansDir, latest.f), 'utf8');
const title = content.match(/^#\s+(.+)$/m)?.[1]
?.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const outName = title ? `${title}.md` : latest.f;
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
const outDir = path.join(projectDir, 'docs', 'plans');
fs.mkdirSync(outDir, { recursive: true });
fs.copyFileSync(
path.join(plansDir, latest.f),
path.join(outDir, outName)
);
process.exit(0);
Further Reading / Resources#
- Claude Code documentation (hooks, settings, tool lifecycle).