Surviving compaction, API failures and plan drift

Six hooks that keep the plan workflow coherent across the things that normally destroy it — the plan STOP, compaction re-injection, and the StopFailure breadcrumb.

View source
On this page
  1. plan-stop-reminder.sh
  2. log-progress.sh (async)
  3. What it used to do
  4. precompact-rules.sh
  5. postcompact-verify.sh
  6. stop-failure-log.sh
  7. check-pending-plans.sh
  8. Why systemMessage and not additionalContext
  9. Related

About this hook group

Hooks PostToolUsePreCompactPostCompactStopFailureStop

Six hooks that keep the plan workflow coherent across the things that normally destroy it — the plan STOP, compaction re-injection, and the StopFailure breadcrumb.

Workflow State (PostToolUse, PreCompact, PostCompact, StopFailure, Stop)

Six hooks that keep the plan workflow coherent across the things that normally destroy it: compaction, API failures, and closing the laptop.

The filesystem is the state machine — plan.md checkboxes are the source of truth — so these hooks exist to make sure Claude keeps looking at that state.

ScriptEventJob
plan-stop-reminder.shPostToolUse on Write(*plan.md)Stop after planning; do not auto-implement
log-progress.shPostToolUse on Edit/WriteAppend edit metrics (JSONL)
precompact-rules.shPreCompactRe-inject phase rules before context is squashed
postcompact-verify.shPostCompactConfirm plan state survived
stop-failure-log.shStopFailureRecord an API failure for next session
check-pending-plans.shStopWarn about session-created risks on exit

plan-stop-reminder.sh

The single most behavior-changing hook in the plugin. When a new plan.md is written, it hard-stops the run:

==========================================
STOP: Plan file created.
==========================================
Do NOT proceed to implementation.
Present a brief summary of the plan to the user,
then use AskUserQuestion with options:
  - Start in fresh session (recommended)
  - Get a briefing (/phx:brief)
  - Start here
  - Review or adjust the plan
==========================================

Planning burns a lot of context. Rolling straight into implementation in the same window means the execution phase starts already half-full, which is exactly when quality drops.

Three conditions must all hold:

  1. The path matches .claude/plans/<slug>/plan.md.
  2. It was a Write, not an Edit. Write carries tool_input.content; Edit carries old_string. Checking for content distinguishes plan creation from routine checkbox updates.
  3. /phx:full autonomous mode is not active — detected by a progress.md containing a **State**: field, written by workflow-orchestrator during INITIALIZING. Stopping an autonomous cycle to ask a question would defeat it.

log-progress.sh (async)

Appends one JSON line per edit to $CLAUDE_PLUGIN_DATA/skill-metrics/edits-YYYY-MM.jsonl:

{"ts":"2026-08-19T10:22:31+02:00","file":"lib/my_app/accounts.ex","project":"my_app"}

Feeds /skill-monitor. Fully silent, never blocks, || true on every write.

What it used to do

Until v2.8.3 this hook appended to progress.md. It picked the most recently modified progress.md across all plans, so with more than one plan in flight it wrote entries into unrelated plans (issue #38). The /phx:work skill logs structured progress itself, so the hook’s version was both redundant and wrong. It was replaced with metrics-only.


precompact-rules.sh

Before compaction, re-injects the rules for whatever phase you are in.

Iron Laws live in the system prompt and survive compaction on their own. What does not survive is skill-specific procedure loaded into conversation context — so only that is re-injected.

The hook detects the phase from the filesystem:

Detected stateRe-injected
progress.md has **State**:/phx:full autonomous mode — continue the cycle, re-read progress.md for cycle count
research/ exists, no plan.md; or plan.md has Status: PENDING/phx:plan — the STOP-after-planning rule, verbatim
plan.md has unchecked - [ ] tasks/phx:work — verify after every task, max 3 retries then BLOCKER, never auto-start /phx:review

It also appends the Dead Ends section of the active plan’s scratchpad:

SCRATCHPAD Dead Ends (DO NOT RETRY these approaches):
- Tried supervising the cache in the app tree — Repo isn't started yet at that point

Losing that across a compaction means re-walking a dead end you already paid for.

Output goes through JSON systemMessage. PreCompact supports only top-level fields — there is no hookSpecificOutput.additionalContext for this event.


postcompact-verify.sh

The other half of the pair. After compaction, if any plan still has unchecked tasks:

POST-COMPACTION: Active plan 'multi-agent-port' detected.
Re-read .claude/plans/multi-agent-port/plan.md and
.claude/plans/multi-agent-port/scratchpad.md to restore context.

Uses exit 2 + stderr, the same channel as PostToolUse.


stop-failure-log.sh

Fires when a turn ends because of an API error. Appends to the most recent plan’s scratchpad:

## API Failure — 2026-08-19 14:32

Turn ended due to API error. Check progress.md for last completed task.
Resume with: /phx:work --continue

The write is the entire job. Claude Code ignores this event’s exit code and output completely — it cannot block and it cannot message. An earlier version emitted exit 2 + stderr, which was a silent no-op.

The signal propagates through the filesystem instead: next session, check-scratchpad.sh reads that scratchpad and surfaces it in the startup banner. That is the whole mechanism.


check-pending-plans.sh

Fires on every Stop, and stays silent almost always.

It only speaks when there is a session-created signal the user could not otherwise know about — running background_tasks[] or scheduled session_crons[] (both added to Stop input in Claude Code 2.1.145). A forgotten mix phx.server is exactly the thing SessionStart cannot warn you about tomorrow.

⚠ Before you leave:
  • 1 background task(s) still running — stop them or detach explicitly
  • 3 plan(s) have uncompleted tasks
  • 7 uncommitted change(s) on 'feat/notifications' — commit or stash before switching/rebasing

Pending plans and a dirty tree appear only as supporting context once a background signal has already fired. They are already surfaced at SessionStart by check-resume.sh and check-branch-freshness.sh, so re-warning them on every single turn would be pure noise. On a clean stop the hook exits before spawning git or grep at all.

Why systemMessage and not additionalContext

This is the Stop event’s defining constraint:

ChannelEffect
Plain stdoutDebug log only — never shown in the transcript
additionalContext / exit 2Feeds Claude and continues the turn
JSON systemMessageShown to the user; Claude still stops

An advisory reminder must not force Claude to keep working, so systemMessage is the only correct channel. The hook also exits early when stop_hook_active is true, which prevents a recursive stop loop.