Hooks
What compact-plus runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add u-ichi/compact-plus > /plugin install compact-plus@compact-plus
Ships with compact-plus. Installing the plugin gets these hooks.
What fires, and when
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
bash "${CLAUDE_PLUGIN_ROOT}/hooks/userpromptsubmit-compaction-recovery.sh"bash "${CLAUDE_PLUGIN_ROOT}/hooks/userpromptsubmit-compact-plus-reminder.sh"
PreCompact
bash "${CLAUDE_PLUGIN_ROOT}/hooks/precompact-transcript-backup.sh"bash "${CLAUDE_PLUGIN_ROOT}/hooks/precompact-state-summary.sh"
PostCompact
bash "${CLAUDE_PLUGIN_ROOT}/hooks/compaction-recovery.sh"
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
- Matches
compactbash "${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart-compaction-recovery.sh" bash "${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart-export-session-id.sh"
Where it lives
- hooks/compaction-recovery.shRunsGitHub
Read the script
#!/bin/bash # PostCompact hook (matcher: ""): record compaction with a marker file. # PostCompact does not support additionalContext output, so the marker is # consumed by SessionStart(source=compact), which injects the saved state. Codex # thread-spawn subagents get no start hook after compaction, so UserPromptSubmit # stays as their delivery channel. # # fail-open (always exit 0) set -uo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../scripts/runtime-paths.sh source "$SCRIPT_DIR/../scripts/runtime-paths.sh" INPUT=$(cat) SESSION_ID=$(compact_plus_artifact_key "$INPUT") [[ -z "$SESSION_ID" ]] && exit 0 # On Claude Code, SessionStart(source=compact) runs before this hook and leaves an # injected mark once it has delivered the state. Consume that mark instead of # writing a marker, otherwise UserPromptSubmit would inject the same state a # second time on the next prompt. INJECTED="$COMPACT_PLUS_INJECTED_DIR/$SESSION_ID" if [[ -f "$INJECTED" ]]; then rm -f "$INJECTED" 2>/dev/null || true else # Write the marker file. The recovery hook that runs next consumes it once. MARKER_DIR="$COMPACT_PLUS_MARKER_DIR" mkdir -p "$MARKER_DIR" 2>/dev/null || true printf '%s\n' "$(date +%s)" > "$MARKER_DIR/$SESSION_ID" 2>/dev/null || true fi # Reset the compact reminder cooldown after compact runs. WARN_DIR="$COMPACT_PLUS_WARNED_DIR" rm -f "$WARN_DIR/$SESSION_ID" 2>/dev/null || true exit 0 - hooks/precompact-state-summary.shRunsGitHub
Read the script
#!/usr/bin/env bash # PreCompact hook: create or update a compact-plus state file from the transcript. # fail-open: do not block compaction when config, LLM, or filesystem work fails. set -euo pipefail trap 'exit 0' ERR SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../scripts/runtime-paths.sh source "$SCRIPT_DIR/../scripts/runtime-paths.sh" COMPACT_PLUS_PLUGIN_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) PROMPT_FILE="$COMPACT_PLUS_PLUGIN_ROOT/prompts/state-summary.md" STATE_DIR="$COMPACT_PLUS_STATE_DIR" OFFSET_DIR="$COMPACT_PLUS_OFFSET_DIR" COUNTER_DIR="$COMPACT_PLUS_COUNTER_DIR" COMPACT_PLUS_TRANSCRIPT_MODE="${COMPACT_PLUS_TRANSCRIPT_MODE:-incremental}" COMPACT_PLUS_TRANSCRIPT_HEAD_TURNS="${COMPACT_PLUS_TRANSCRIPT_HEAD_TURNS:-5}" COMPACT_PLUS_TRANSCRIPT_TAIL_TURNS="${COMPACT_PLUS_TRANSCRIPT_TAIL_TURNS:-25}" COMPACT_PLUS_TRANSCRIPT_HEAD_KB="${COMPACT_PLUS_TRANSCRIPT_HEAD_KB:-10}" COMPACT_PLUS_TRANSCRIPT_TAIL_KB="${COMPACT_PLUS_TRANSCRIPT_TAIL_KB:-40}" COMPACT_PLUS_INCREMENTAL_REFRESH="${COMPACT_PLUS_INCREMENTAL_REFRESH:-10}" COMPACT_PLUS_MAX_OUTPUT_TOKENS="${COMPACT_PLUS_MAX_OUTPUT_TOKENS:-4096}" COMPACT_PLUS_SQUASH_ENABLED="${COMPACT_PLUS_SQUASH_ENABLED:-1}" COMPACT_PLUS_SQUASH_READ_LINES="${COMPACT_PLUS_SQUASH_READ_LINES:-100}" COMPACT_PLUS_SQUASH_BASH_CHARS="${COMPACT_PLUS_SQUASH_BASH_CHARS:-500}" COMPACT_PLUS_TWO_PASS="${COMPACT_PLUS_TWO_PASS:-1}" COMPACT_PLUS_RAW_DELTA_FACTOR="${COMPACT_PLUS_RAW_DELTA_FACTOR:-20}" # hooks.json は本 hook 全体に 180 秒を与える。primary と fallback は直列に走るので、 # 個々の上限を 180 秒にすると primary が固まった時点で hook ごと kill され fallback に # 到達できない。両方を試したうえで前処理の余地も残る値にする。 COMPACT_PLUS_BACKEND_TIMEOUT="${COMPACT_PLUS_BACKEND_TIMEOUT:-80}" DEFAULT_PRIMARY_BACKEND='claude -p --model claude-sonnet-5 --effort medium --permission-mode dontAsk --output-format text --no-session-persistence --system-prompt "$SYSTEM_PROMPT"' PRIMARY_CMD="${COMPACT_PLUS_PRIMARY_BACKEND-$DEFAULT_PRIMARY_BACKEND}" DEFAULT_FALLBACK_BACKEND='tmp=$(mktemp "${TMPDIR:-/tmp}/compact-plus-codex.XXXXXX"); { printf "%s\n\n" "$SYSTEM_PROMPT"; cat; } | codex exec --model gpt-5.3-codex-spark --sandbox read-only --skip-git-repo-check --dangerously-bypass-hook-trust --ignore-user-config --ephemeral --output-last-message "$tmp" - >/dev/null && cat "$tmp"; status=$?; rm -f "$tmp"; exit "$status"' FALLBACK_CMD="${COMPACT_PLUS_FALLBACK_BACKEND-$DEFAULT_FALLBACK_BACKEND}" INPUT=$(cat) SESSION_ID=$(compact_plus_artifact_key "$INPUT") TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || true) TRIGGER=$(printf '%s' "$INPUT" | jq -r '.trigger // "unknown"' 2>/dev/null || printf 'unknown') CUSTOM_INSTRUCTIONS=$(printf '%s' "$INPUT" | jq -r '.custom_instructions // empty' 2>/dev/null || true) [[ -n "$SESSION_ID" ]] || exit 0 [[ -n "$TRANSCRIPT_PATH" ]] || exit 0 [[ -f "$TRANSCRIPT_PATH" ]] || exit 0 [[ -f "$PROMPT_FILE" ]] || exit 0 command -v jq >/dev/null 2>&1 || exit 0 STATE_FILE="$STATE_DIR/$SESSION_ID.md" OFFSET_FILE="$OFFSET_DIR/$SESSION_ID" COUNTER_FILE="$COUNTER_DIR/$SESSION_ID" mkdir -p "$STATE_DIR" "$OFFSET_DIR" "$COUNTER_DIR" 2>/dev/null || true cap_bytes() { local kb="$1" local mode="$2" local max_bytes=$((kb * 1024)) if [[ "$max_bytes" -le 0 ]]; then cat elif [[ "$mode" == "tail" ]]; then tail -c "$max_bytes" else head -c "$max_bytes" fi } jq_string() { local filter="$1" local json="$2" printf '%s' "$json" | jq -r "$filter" 2>/dev/null || true } extract_text() { local json="$1" jq_string ' def text_value: if type == "string" then . elif type == "array" then ([.[]? | if type == "string" then . elif type == "object" then (.text? // .content? // "") else "" end] | join("\n")) elif type == "object" then (.text? // .content? // "") else "" end; [ (.content? | text_value), (.message.content? | text_value), (.result? | text_value), (.output? | text_value), (.tool_result? | text_value), (.payload.content? | text_value), (.payload.message.content? | text_value), (.payload.output? | text_value), (.payload.text? | text_value) ] | map(select(. != null and . != "")) | first // "" ' "$json" } extract_tool_name() { local json="$1" jq_string ' [ .tool_name?, .name?, .payload.name?, (.message.content[]? | objects | select(.type? == "tool_use") | .name?), (.message.content[]? | objects | select(.type? == "tool_result") | .name?) ] | map(select(. != null and . != "")) | first // "" ' "$json" } extract_refs() { local json="$1" local refs refs=$(printf '%s' "$json" | jq -r '.. | strings | select(test("(/[^[:space:]\"'\'']+|https?://[^[:space:]\"'\'']+)"))' 2>/dev/null | head -n 5 | tr '\n' ' ' || true) refs=${refs% } printf '%s' "${refs:-unknown path}" } process_json_line() { local line="$1" local json tool text refs line_count char_count exit_code match_count payload_type json=$(printf '%s' "$line" | jq -c '.' 2>/dev/null) || { printf '%s\n' "$line" return } [[ "$COMPACT_PLUS_SQUASH_ENABLED" == "1" ]] || { printf '%s\n' "$json" return } tool=$(extract_tool_name "$json") text=$(extract_text "$json") refs=$(extract_refs "$json") line_count=$(printf '%s' "$text" | awk 'END { print NR }') char_count=${#text} payload_type=$(jq_string '.payload.type // .type // ""' "$json") if [[ "$payload_type" == "function_call_output" && "$char_count" -gt "$COMPACT_PLUS_SQUASH_BASH_CHARS" ]]; then printf '[ToolResult: %s chars output; refs: %s]\n' "$char_count" "$refs" return fi case "$tool" in Read) if [[ "$line_count" -gt "$COMPACT_PLUS_SQUASH_READ_LINES" ]]; then printf '[Read: %s lines from %s]\n' "$line_count" "$refs" return fi ;; Bash) if [[ "$char_count" -gt "$COMPACT_PLUS_SQUASH_BASH_CHARS" ]]; then exit_code=$(jq_string '.exit_code // .status // .metadata.exit_code // "unknown"' "$json") printf ' - hooks/precompact-transcript-backup.shRunsGitHub
Read the script
#!/usr/bin/env bash # PreCompact hook: copy transcript_path to persistent backup storage. # fail-open: do not block compaction even when backup fails. set -euo pipefail trap 'exit 0' ERR SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../scripts/runtime-paths.sh source "$SCRIPT_DIR/../scripts/runtime-paths.sh" INPUT=$(cat) SESSION_ID=$(compact_plus_artifact_key "$INPUT") TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || true) [[ -n "$SESSION_ID" ]] || exit 0 [[ -n "$TRANSCRIPT_PATH" ]] || exit 0 [[ -f "$TRANSCRIPT_PATH" ]] || exit 0 BACKUP_DIR="$COMPACT_PLUS_BACKUP_DIR" mkdir -p "$BACKUP_DIR" 2>/dev/null || true EPOCH=$(date +%s) DEST="$BACKUP_DIR/${EPOCH}-${SESSION_ID}.jsonl" cp "$TRANSCRIPT_PATH" "$DEST" 2>/dev/null || exit 0 # mapfile is a bash 4+ builtin and stock macOS /bin/bash is 3.2, so use a # POSIX while-read loop to prune old backups instead. find "$BACKUP_DIR" -maxdepth 1 -type f -name "*-${SESSION_ID}.jsonl" -print 2>/dev/null \ | sort -r \ | tail -n +21 \ | while IFS= read -r old; do rm -f "$old" 2>/dev/null || true done exit 0 - hooks/sessionstart-compaction-recovery.shRunsGitHub
Read the script
#!/usr/bin/env bash # SessionStart(source=compact): inject the state saved by PreCompact exactly once. # # The two runtimes disagree on the order of SessionStart(source=compact) and # PostCompact inside a single compaction. Claude Code dispatches SessionStart # first, so the PostCompact marker does not exist yet. Codex dispatches # PostCompact first, so the marker is already on disk. Gating on the marker alone # makes the first compaction on Claude Code inject nothing, so this hook accepts # either order: # # marker present -> PostCompact already ran; consume it and inject # no marker, state present -> SessionStart ran first; inject and leave an # injected mark so the later PostCompact knows the # state was already delivered and skips its marker # mark newer than state -> already injected for this compaction; stay quiet # neither -> do nothing, so the PostCompact -> UserPromptSubmit # fallback still delivers the state # # The injected mark is only trusted while it is newer than the state file it # covers. A PostCompact that never finishes (hook timeout, kill, crash) leaves the # mark behind, and treating a leftover mark as authoritative would silence the # NEXT compaction completely: SessionStart would skip, PostCompact would consume # the mark instead of arming a marker, and the UserPromptSubmit fallback would # have nothing to deliver. Comparing against the state file's timestamp keeps a # leaked mark from costing more than the compaction it leaked from. # # fail-open (always exit 0) set -uo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../scripts/runtime-paths.sh source "$SCRIPT_DIR/../scripts/runtime-paths.sh" # Cap the inlined state so a large state file cannot crowd out the context it is # meant to restore. The full file stays on disk and is referenced when truncated. STATE_MAX_BYTES=30720 # Modification time in epoch seconds. GNU stat first, then BSD; the numeric guard # keeps a wrong-platform invocation from returning its diagnostic output. compact_plus_mtime_epoch() { local out if out=$(stat -c '%Y' "$1" 2>/dev/null) && [[ "$out" =~ ^[0-9]+$ ]]; then printf '%s' "$out" return 0 fi if out=$(stat -f '%m' "$1" 2>/dev/null) && [[ "$out" =~ ^[0-9]+$ ]]; then printf '%s' "$out" return 0 fi return 1 } # When PreCompact's backend fails, the previous compaction's state file is left in # place and injected again. Stating when it was written lets the reader judge how # current it is instead of assuming it describes the work just compacted. compact_plus_saved_at() { local epoch epoch=$(compact_plus_mtime_epoch "$1") || return 1 date -u -r "$epoch" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ || date -u -d "@$epoch" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ || return 1 } INPUT=$(cat) SESSION_ID=$(compact_plus_artifact_key "$INPUT") [[ -z "$SESSION_ID" ]] && exit 0 HOOK_EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // empty' 2>/dev/null) [[ "$HOOK_EVENT" == "SessionStart" ]] || exit 0 SOURCE=$(printf '%s' "$INPUT" | jq -r '.source // empty' 2>/dev/null) [[ "$SOURCE" == "compact" ]] || exit 0 MARKER="$COMPACT_PLUS_MARKER_DIR/$SESSION_ID" INJECTED="$COMPACT_PLUS_INJECTED_DIR/$SESSION_ID" STATE_FILE="$COMPACT_PLUS_STATE_DIR/$SESSION_ID.md" # Decide which order this is before producing any output. CONSUME_MARKER=0 WRITE_INJECTED=0 if [[ -f "$MARKER" ]]; then # Codex order. An injected mark alongside the marker is abnormal; clear both so # the pair cannot trigger a second injection later. CONSUME_MARKER=1 elif [[ ! -f "$STATE_FILE" ]]; then # Nothing was saved for this thread, so there is nothing to inject and # PostCompact still arms the marker for the fallback. exit 0 elif [[ -f "$INJECTED" && ! "$STATE_FILE" -nt "$INJECTED" ]]; then # The mark is at least as new as the state it covers, so this compaction was # already delivered and a repeated SessionStart must stay quiet. Equal # timestamps count as delivered: the mark is always written after the state # file, so a tie means the same compaction, never a leftover from an earlier # one. exit 0 else # Claude Code order, or a mark left over from a compaction whose PostCompact # never ran. Either way the state file is newer than any mark, so it has not # been delivered yet. WRITE_INJECTED=1 fi # Read the active plan path from the session pointer file. PLAN_FILE="" if [[ -f "$COMPACT_PLUS_PLAN_POINTER_DIR/$SESSION_ID" ]]; then PLAN_FILE=$(cat "$COMPACT_PLUS_PLAN_POINTER_DIR/$SESSION_ID" 2>/dev/null || true) [[ -f "$PLAN_FILE" ]] || PLAN_FILE="" fi CTX="[COMPACTION RECOVERY] Context compaction occurred. Restore the saved state before resuming work." CTX+=$'\n' if [[ -n "$PLAN_FILE" ]]; then CTX+=$'\n'"Active plan file: ${PLAN_FILE}" CTX+=$'\n'"Re-read that plan and preserve its current phase and constraints." fi if [[ -f "$STATE_FILE" ]]; then SAVED_AT=$(compact_plus_saved_at "$STATE_FILE" || true) if [[ -n "$SAVED_AT" ]]; then CTX+=$'\n'"State saved at: ${SAVED_AT} (if this predates the work just compacted, state generation failed and this is the previous snapshot)." fi STATE_BYTES=$(wc -c < "$STATE_FILE" 2>/dev/null | tr -d ' ') if [[ "${STATE_BYTES:-0}" -gt "$STATE_MAX_BYTES" ]]; then # Drop the final line of the byte slice: it is the only one the cut can split, # and half a multi-byte character would make the JSON payload invalid. CTX+=$'\n\n'"$(head -c "$STATE_MAX_BYTES" "$STATE_FILE" 2>/dev/null | sed '$d')" CTX+=$'\n\n'"State truncated at ${STATE_MAX_BYTES} bytes. Read \`${STATE_FILE}\` for the full state." else CTX+=$'\n\n'"$(cat "$STATE_FILE" 2>/dev/null)" fi else BACKUP_FILE=$(find "$COMPACT_PLUS_BACKUP_DIR" -maxdepth 1 -type f -name "*-${SESSION_ID}.jsonl" -print 2>/dev/null | sort -r | head -n 1 || true) if [[ -n "$BACKUP_FILE" && -f "$BAC - hooks/sessionstart-export-session-id.shRunsGitHub
Read the script
#!/bin/bash # SessionStart hook: export the current session id to CLAUDE_ENV_FILE so the # /compact-plus skill (and any other in-session Bash call) can read it as # $CLAUDE_CODE_SESSION_ID. Claude Code does not expose session_id as a Bash env # var by default; hooks receive it only through the stdin JSON payload. # # fail-open (always exit 0) set -uo pipefail # Codex exports CODEX_THREAD_ID directly and does not use CLAUDE_ENV_FILE. [[ -n "${PLUGIN_ROOT:-}" || "${COMPACT_PLUS_RUNTIME:-}" == "codex" ]] && exit 0 INPUT=$(cat) SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || true) [[ -n "$SESSION_ID" ]] || exit 0 [[ -n "${CLAUDE_ENV_FILE:-}" ]] || exit 0 # Append (not overwrite) to preserve exports from other SessionStart hooks. printf 'export CLAUDE_CODE_SESSION_ID=%s\n' "$SESSION_ID" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true exit 0 - hooks/userpromptsubmit-compact-plus-reminder.shRunsGitHub
Read the script
#!/bin/bash # UserPromptSubmit hook: notify when context usage crosses the runtime-specific # threshold and prompt compact-plus use through additionalContext (one-shot). # # Claude consumes the statusline marker. Codex reads the latest token_count # event from the current thread rollout. PostCompact resets either cooldown. # # fail-open (always exit 0) set -uo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../scripts/runtime-paths.sh source "$SCRIPT_DIR/../scripts/runtime-paths.sh" INPUT=$(cat) SESSION_ID=$(compact_plus_artifact_key "$INPUT") [[ -z "$SESSION_ID" ]] && exit 0 WARN_DIR="$COMPACT_PLUS_WARN_DIR" WARNED_DIR="$COMPACT_PLUS_WARNED_DIR" WARN_MARKER="$WARN_DIR/$SESSION_ID" WARNED_MARKER="$WARNED_DIR/$SESSION_ID" if [[ "$COMPACT_PLUS_RUNTIME_NAME" == "codex" ]]; then # Codex hook input does not include context usage. Read the latest token_count # event from this thread's own rollout after verifying the session id. [[ -f "$WARNED_MARKER" ]] && exit 0 TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) [[ -n "$TRANSCRIPT_PATH" && -f "$TRANSCRIPT_PATH" ]] || exit 0 THRESHOLD="${COMPACT_PLUS_CODEX_WARN_THRESHOLD:-75}" [[ "$THRESHOLD" =~ ^[0-9]+$ ]] || THRESHOLD=75 [[ "$THRESHOLD" -ge 1 && "$THRESHOLD" -le 100 ]] || THRESHOLD=75 BASELINE_TOKENS=12000 ROLLOUT_SESSION_ID=$( head -n 20 "$TRANSCRIPT_PATH" 2>/dev/null \ | jq -r 'select(.type == "session_meta") | .payload.id // empty' 2>/dev/null \ | head -n 1 ) [[ "$ROLLOUT_SESSION_ID" == "$SESSION_ID" ]] || exit 0 USAGE=$( tail -n 500 "$TRANSCRIPT_PATH" 2>/dev/null \ | jq -rs ' [ .[] | select(.type == "event_msg" and .payload.type == "token_count") | .payload.info | select( (.last_token_usage.total_tokens | type) == "number" and (.model_context_window | type) == "number" and .model_context_window > 0 ) ] | last | [.last_token_usage.total_tokens, .model_context_window] | @tsv ' 2>/dev/null || true ) [[ -n "$USAGE" ]] || exit 0 USED_TOKENS=${USAGE%%$'\t'*} CONTEXT_WINDOW=${USAGE#*$'\t'} [[ "$CONTEXT_WINDOW" -gt "$BASELINE_TOKENS" ]] || exit 0 EFFECTIVE_WINDOW=$((CONTEXT_WINDOW - BASELINE_TOKENS)) EFFECTIVE_USED=$((USED_TOKENS - BASELINE_TOKENS)) [[ "$EFFECTIVE_USED" -gt 0 ]] || EFFECTIVE_USED=0 CTX_PCT=$((EFFECTIVE_USED * 100 / EFFECTIVE_WINDOW)) [[ "$CTX_PCT" -le 100 ]] || CTX_PCT=100 [[ "$CTX_PCT" -ge "$THRESHOLD" ]] || exit 0 else # Claude's statusline owns percentage calculation and writes this marker. [[ -f "$WARN_MARKER" ]] || exit 0 CTX_PCT=$(cat "$WARN_MARKER" 2>/dev/null) CTX_PCT=${CTX_PCT:-"?"} rm -f "$WARN_MARKER" 2>/dev/null || true fi # Create the cooldown marker so the same session is not notified repeatedly. mkdir -p "$WARNED_DIR" 2>/dev/null || true printf '%s\n' "$(date +%s)" > "$WARNED_MARKER" 2>/dev/null || true STATE_FILE="$COMPACT_PLUS_STATE_DIR/$SESSION_ID.md" section_first_line() { local heading="$1" local file="$2" awk -v heading="$heading" ' $0 == heading { in_section = 1; next } in_section && /^## / { exit } in_section { line = $0 sub(/^[[:space:]-]+/, "", line) if (line != "") { print line exit } } ' "$file" 2>/dev/null } section_last_line() { local heading="$1" local file="$2" awk -v heading="$heading" ' $0 == heading { in_section = 1; next } in_section && /^## / { exit } in_section { line = $0 sub(/^[[:space:]-]+/, "", line) if (line != "") { last = line } } END { if (last != "") print last } ' "$file" 2>/dev/null } CTX="[COMPACT REMINDER] context usage reached ${CTX_PCT}%." if [[ -f "$STATE_FILE" ]]; then ACTIVE_PLAN=$(section_first_line "## Active Plan" "$STATE_FILE") CURRENT_PHASE=$(section_first_line "## Current Phase" "$STATE_FILE") SESSION_DECISION=$(section_last_line "## Session Decisions" "$STATE_FILE") CTX+=$'\n'"State recitation:" CTX+=$'\n'"- Active Plan: ${ACTIVE_PLAN:-Not verified}" CTX+=$'\n'"- Current Phase: ${CURRENT_PHASE:-Not verified}" CTX+=$'\n'"- Recent Session Decision: ${SESSION_DECISION:-Not verified}" else CTX+=$'\n'"State recitation: no pre-compaction state file is available for this session." fi CTX+=$'\n'"- At a work boundary, tell the user they can run \`/compact\` as-is. The PreCompact hook automatically saves pre-compaction state." CTX+=$'\n'"- Address the situation by saving pre-compaction state, not by shrinking scope or moving to another session." jq -n --arg ctx "$CTX" '{ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: $ctx } }' exit 0 - hooks/userpromptsubmit-compaction-recovery.shRunsGitHub
All 7 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
A transparent Claude Code and Codex plugin that preserves working state around /compact. It does not replace either runtime's compaction algorithm; it augments compaction through documented hooks.

