Assistants Playbook

Make Claude Code re-read your plan after every compaction: a SessionStart brief that also warns before a stale resume

One 60-line hook rebuilds the agent's bearings from git and a plan file after /clear and compaction, and puts a price on resuming a cold session. About fifteen minutes to set up.

Part of the hooks reading guide

A stack of charcoal paper sheets with a torn gap through the middle layers, filled by a single bright violet paper cut-out

Two hours into a refactor, the context window fills and Claude Code compacts. The summary is decent. It also says nothing about the decision you made at minute 20 not to add a dependency, and the next thing the agent does is propose adding one. Or you hit /clear between tasks, and the fresh session has no idea which branch it’s on, what’s uncommitted, or that a plan exists.

The fix isn’t a better summary. It’s a source of truth that lives outside the conversation, plus a hook that hands it back to the agent whenever the conversation resets. Claude Code has a hook event built for exactly that, and a second, lesser-known capability on the same event: on resume it reports what re-sending a stale conversation will cost, before the first request goes out.

Here’s the setup. A plan file the agent maintains, one SessionStart hook that rebuilds a brief from git and that file, and an optional PostCompact hook that saves what the compaction summary kept. It takes about fifteen minutes. The outputs below come from Claude Code 2.1.310.

The idea: the plan on disk wins

Compaction is lossy by design. The conversation is replaced by a summary, and anything the summary leaves out is gone from the agent’s view. A file in the repo isn’t lossy. If the agent writes its plan, its decisions and its next step to .claude/PLAN.md as it works, any new context can be pointed back at the file.

The pointing is the part people skip. A line in CLAUDE.md saying “check PLAN.md” works until the agent doesn’t, and after a compaction it often doesn’t. A SessionStart hook doesn’t ask. The hooks reference says its additionalContext is inserted at the start of the conversation, before the first prompt, and the compact matcher fires after both manual and automatic compaction.

The matchers on SessionStart describe how the session began:

MatcherFires on
startupA new session
resume--resume, --continue or /resume
clear/clear
compactAuto or manual compaction
forkA session forked from an existing one

The brief goes to startup, clear and compact. On resume the old transcript is already in the context, so there’s nothing to rebuild. That’s where the cost warning goes instead.

Step 1: a plan file the agent keeps current

Create .claude/PLAN.md. The shape matters less than the habit, but a structure with three parts works: the goal, a checklist, and the decisions that must survive.

# Plan: CSV export

Goal: add `exportCsv(report)` to src/reports. Streams rows, quotes commas.

- [x] 1. Characterise existing `formatRow` (tests in src/a.test.ts)
- [ ] 2. Add `exportCsv` with RFC 4180 quoting
- [ ] 3. Wire into /reports/:id/export route
- [ ] 4. Update docs/export.md

Decisions:
- No new dependencies. Hand-roll the quoting.
- Streaming, not buffering: reports reach 200k rows.

Next: step 2, failing test first.

The Decisions section is the one that earns its keep. Checklists are easy to reconstruct from the diff. The reasons you ruled something out aren’t in the diff anywhere, and they’re exactly what a summary drops.

Then tell the agent to maintain it. Two lines in CLAUDE.md are enough:

## Working state
For any task with more than three steps, keep `.claude/PLAN.md` current: tick steps as they finish,
record decisions and rejected options under "Decisions", and update "Next" before stopping.

Commit PLAN.md or gitignore it, depending on whether the plan is a team artefact or a personal scratchpad. The hook doesn’t care, because it reads from disk.

Step 2: the SessionStart hook

Save this as .claude/hooks/session-brief.py. It’s Python because the hook needs git calls, JSON in and JSON out, and it should run the same on macOS, Linux and Windows without a compile step.

#!/usr/bin/env python3
"""SessionStart hook: re-brief the agent after /clear or compaction, and
warn before a stale resume re-sends a big context at cache-write prices."""
import json
import subprocess
import sys

PLAN = ".claude/PLAN.md"
PLAN_CAP = 3000        # characters of the plan to inject
WARN_USD = 0.50        # warn on resume above this estimated cache-write cost


def git(*args):
    try:
        out = subprocess.run(["git", *args], capture_output=True, text=True, timeout=5)
        return out.stdout.strip()
    except Exception:
        return ""


def read_plan():
    try:
        with open(PLAN, encoding="utf-8") as f:
            text = f.read()
    except OSError:
        return ""
    if len(text) > PLAN_CAP:
        text = text[:PLAN_CAP] + "\n[plan truncated, read " + PLAN + " for the rest]"
    return text


event = json.load(sys.stdin)
source = event.get("source", "startup")
out = {"hookSpecificOutput": {"hookEventName": "SessionStart"}}

if source == "resume" and event.get("prompt_cache_likely_expired"):
    usd = event.get("estimated_cache_write_usd") or 0
    if usd >= WARN_USD:
        tokens = event.get("context_tokens", 0)
        idle = int(event.get("seconds_since_last_response", 0) // 60)
        out["systemMessage"] = (
            f"Resuming re-sends {tokens:,} tokens after {idle} min idle. "
            f"Estimated cache write: ${usd:.2f}. Starting fresh from {PLAN} "
            f"avoids it; /compact on a cold cache costs the most."
        )

if source in ("startup", "clear", "compact"):
    lines = [f"Session brief ({source}). Rebuilt from git and {PLAN}, not from memory."]
    lines.append("Branch: " + (git("branch", "--show-current") or "(detached)"))
    status = git("status", "--short")
    lines.append("Uncommitted:\n" + (status if status else "(clean)"))
    lines.append("Recent commits:\n" + (git("log", "--oneline", "-5") or "(none)"))
    plan = read_plan()
    if plan:
        lines.append(f"Plan ({PLAN}):\n{plan}")
    else:
        lines.append(f"No {PLAN} yet. If the task spans more than a few steps, create one.")
    out["hookSpecificOutput"]["additionalContext"] = "\n\n".join(lines)

json.dump(out, sys.stdout)

Three details are deliberate. The plan is capped at 3,000 characters because the docs cap a hook’s additionalContext, systemMessage and plain stdout at 10,000 characters, and a brief that crowds out the work defeats the point. Every git call has a timeout and swallows errors, since a SessionStart hook that hangs delays the agent’s first response (the docs say Claude’s first response waits for these hooks). And the script reads the plan relative to the working directory, which for a hook is where the session started.

Step 3: register it

Put this in .claude/settings.json:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|clear|compact|resume",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/session-brief.py\""
          }
        ]
      }
    ]
  }
}

${CLAUDE_PROJECT_DIR} is the documented placeholder for the project root, so the hook works wherever the session was started inside the repo. On Windows, where the interpreter is usually python or py, swap the command accordingly.

Before wiring it in, run the script by hand with the JSON the harness sends. For a compact event:

echo '{"source":"compact"}' | python3 .claude/hooks/session-brief.py | jq -r .hookSpecificOutput.additionalContext

Run it in a repo with a PLAN.md and a dirty tree and it prints:

Session brief (compact). Rebuilt from git and .claude/PLAN.md, not from memory.

Branch: feat/csv-export

Uncommitted:
M src/a.ts
?? .claude/
?? src/b.ts

Recent commits:
32b4281 Add parser skeleton
7c95035 Initial commit

Plan (.claude/PLAN.md):
# Plan: CSV export

Goal: add `exportCsv(report)` to src/reports. Streams rows, quotes commas.

- [x] 1. Characterise existing `formatRow` (tests in src/a.test.ts)
- [ ] 2. Add `exportCsv` with RFC 4180 quoting
- [ ] 3. Wire into /reports/:id/export route
- [ ] 4. Update docs/export.md

Decisions:
- No new dependencies. Hand-roll the quoting.
- Streaming, not buffering: reports reach 200k rows.

Next: step 2, failing test first.

Watching it work

A fresh headless session in that repo shows the brief arriving before the first prompt. Run:

claude -p "Without using any tools: what branch are you told you're on, and what is the next step in the plan? One sentence."

It answers:

I'm on `feat/csv-export`, and the next step is plan step 2: add `exportCsv` with RFC 4180 quoting, starting with a failing test.

That proves startup. The compaction case is the one that matters, and it’s testable too. The sequence below reads the plan, then ticks step 2 off on disk behind the agent’s back, then compacts, then asks what’s next:

SID=$(python3 -c "import uuid;print(uuid.uuid4())")
claude -p --session-id $SID "Read .claude/PLAN.md and reply with just: ok"
sed -i 's/- \[ \] 2\./- [x] 2./; s/^Next: .*/Next: step 3, wire the route./' .claude/PLAN.md
claude -p --resume $SID "/compact"
claude -p --resume $SID "Without using tools: what is the next step in the plan? One sentence."

The last command prints:

Step 3 is next: wire `exportCsv` into the `/reports/:id/export` route, since the plan now shows steps 1 and 2 done.

The conversation never saw the edit. The agent read the plan before the change, so any answer built from the pre-compaction context would have said step 2. The answer came from the brief the compact hook rebuilt from disk, which is the whole design: the file is the truth, the conversation is a cache of it.

With --output-format stream-json --verbose, the compaction run shows two SessionStart hook responses. The first, for the resume that loads the session, returns an empty object. The second, for compact, returns the brief. The compact_boundary record in that run reported 36,682 tokens before and 6,783 after, which is a good reminder of how much of the conversation is expected to disappear.

Step 4: the cost warning on resume

The second half of the script is for a different moment: you come back to a session after lunch.

From Claude Code 2.1.251, when source is resume or fork and the transcript has at least one response, the hook input carries four extra fields: seconds_since_last_response, context_tokens, prompt_cache_likely_expired and estimated_cache_write_usd. The docs show an input for a session resumed 90 minutes after its last response, with 182,340 context tokens and an estimated cache write of $1.1396. They suggest returning a systemMessage so the person sees the cost before the first request.

The script does that when the cache is likely expired and the estimate passes a threshold. Feed it the documented example:

echo '{"source":"resume","seconds_since_last_response":5400,"context_tokens":182340,"prompt_cache_likely_expired":true,"estimated_cache_write_usd":1.1396}' | python3 .claude/hooks/session-brief.py | jq .
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart"
  },
  "systemMessage": "Resuming re-sends 182,340 tokens after 90 min idle. Estimated cache write: $1.14. Starting fresh from .claude/PLAN.md avoids it; /compact on a cold cache costs the most."
}

Below the threshold, the same event prints an empty hook object and nothing appears. That output was produced by piping JSON into the script. A real stale resume was not exercised, because it needs a session idle past the cache lifetime, so treat the field behaviour as documented rather than observed.

The advice in the message comes from the prompt caching docs , and it runs against instinct. Compaction needs a summarisation request that repeats the whole history. While the cache is warm that reads from cache and is cheap. After a break longer than the cache lifetime, the request reprocesses the history as uncached input, which the docs give as the reason /compact costs the most when you resume an old session. So “compact it before it gets expensive” is the wrong reflex on a cold resume. The plan file makes the alternative cheap: start a fresh session and let the startup brief carry the state.

Whether that beats resuming depends on the session. If the old conversation holds a lot of hard-won debugging context that never made it into the plan, paying for the resume can be right. The warning’s job is to make that a choice instead of a surprise. Set WARN_USD to whatever number makes you look up.

Step 5 (optional): see what the summary kept

PostCompact receives trigger and compact_summary, and has no decision control, so it can’t change the result. It can record it. Add this beside the SessionStart entry in settings.json:

"PostCompact": [
  {
    "hooks": [
      {
        "type": "command",
        "command": "jq -r .compact_summary > \"${CLAUDE_PROJECT_DIR}/.claude/last-compact-summary.md\""
      }
    ]
  }
]

After a compaction the file holds the full summary the agent now works from. Run grep -c "dependenc" .claude/last-compact-summary.md and you learn whether your “no new dependencies” decision made it through. In the run above, a session that had only read the plan produced a 392-word summary that did mention the plan’s content, because the brief had been in context. A session where the decision came up only in conversation is the more interesting test, and it’s the one to run on your own work.

Add .claude/last-compact-summary.md to .gitignore. It’s a log, not a deliverable.

Make it yours

A few variations are worth the extra lines:

  • Pull in the ticket. If branches are named after issues, add a gh issue view call keyed on the branch number and append the title and acceptance criteria. Keep it short: the 10,000-character cap is shared with everything else the hook prints.
  • Add the failing tests. Run your test command with a short timeout and list only the names of failing tests. After a compaction, “these three tests are red” is the most useful sentence the agent can hear. Keep the timeout tight, because the first response waits on the hook.
  • Use watchPaths. A SessionStart hook can return an array of absolute paths to watch for FileChanged events. Return the plan file’s path and a FileChanged hook can tell you when another process edits it.
  • Name the session. The hook can return sessionTitle, which has the same effect as /rename, on startup, resume and fork. Using the branch name keeps the resume picker readable.

What goes wrong

The brief goes stale if the agent doesn’t keep the plan current. The hook can only repeat what’s on disk, and a plan that says “step 2 next” three hours after step 2 finished is worse than no plan, because the agent will trust it. The CLAUDE.md lines help; an occasional glance at git diff .claude/PLAN.md helps more.

It adds tokens to every start. With the plan capped at 3,000 characters the brief is small, but it is paid on every startup, clear and compact. A project that never compacts and never uses /clear gets little from it.

It doesn’t survive a hook failure quietly. If python3 isn’t on the PATH, the hook fails and the agent starts without a brief. Check once with claude -p that the brief arrives, and again after any machine change.

Anything private in the plan goes into the context and into transcripts. Don’t put secrets or customer data in PLAN.md; treat it like a commit message.

And don’t bother at all for short sessions. If a task fits in one context window and finishes in forty minutes, a plan file is overhead. This earns its place when sessions run long enough to compact, or when work spans days and several fresh starts.

The first fifteen minutes

Create the three files: .claude/PLAN.md for the task you’re on now, .claude/hooks/session-brief.py from above, and the settings.json entry. Run the script by hand with {"source":"startup"} to check the output, then start a session and ask it what branch and next step it was given. At the next natural break, run /compact and ask again.