Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "prep-compact",
"description": "When your Claude Code session's real token count crosses a configurable threshold (default 450K), an informational reminder names the warm handoff file. A Stop hook continuously maintains an on-disk handoff JSON (cumulative file paths, in-progress todos, active subagent launches, recent user-message quotes). The /prep-compact skill reads this warm handoff and adds an analytical layer (decisions, constraints, blockers, verb-anchored next-step) to emit a tailored /compact <instructions> block.",
"version": "3.0.0",
"version": "3.0.1",
"author": {
"name": "Koen van der Heide",
"url": "https://github.com/koenvdheide"
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to prep-compact will be documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.0.1] - 2026-06-05

### Fixed

- `/prep-compact` now binds the handoff lookup to the invoking session's id and validates the stored working directory, instead of selecting the newest-modified handoff. Fixes cross-session contamination when several sessions share a working directory.
- The drafted `/compact` command is now enforced single-line (multi-line escape hatch removed, pre-emit verify gate added), preventing a stray newline after `/compact` from dropping the instructions.
- The Stop hook no longer records `.git/` internals or `.claude/plugins/{data,cache}/` paths in the handoff's file lists, and drops Claude Code's injected turns (`<task-notification>`, system notifications, skill-load preambles) from the captured user requests, so the handoff isn't polluted with non-user noise.

## [3.0.0] - 2026-04-26

Adds a Stop hook maintaining a continuously-fresh on-disk handoff. UserPromptSubmit reminder becomes informational. Skill reads warm handoff plus a targeted analytical pass — no fresh full survey when the handoff is current. Four Codex review rounds shaped the design.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ CC does not programatically expose how many tokens are in use for the current se

The reminder fires once per threshold-crossing. Once the token count drops back below the threshold (after you `/compact`), the flag is auto-cleared on the next turn and future crossings re-arm cleanly. You can also invoke `/prep-compact` manually at any time to refresh the draft right before running `/compact`.

The skill resolves *this* session's own handoff by its Claude Code session id (not by newest-modified file), so when several sessions run in the same project, `/prep-compact` reads the invoking session's own record and no other. If no matching handoff exists yet (none written, or you changed directories), or the session id is unavailable, it falls back to surveying the live conversation — never another session's data.


## Install

Expand Down Expand Up @@ -66,6 +68,7 @@ See [PRIVACY.md](PRIVACY.md) for the full statement.
- **Undocumented transcript format.** The hook parses `.message.usage` from the transcript `.jsonl`, which Anthropic doesn't officially document. Silent no-op if the schema changes.
- **Manual invocation.** The reminder is informational — it names the warm handoff path and points at `/prep-compact:prep-compact`. Claude does not auto-invoke the skill; type `/prep-compact` manually when you're ready to compact.
- **Staleness across turns.** The Stop hook refreshes the warm handoff after every assistant message, so `/prep-compact` reads current state. If the conversation has been idle and the handoff has been updated since the last user prompt, the draft will reflect that. There's a one-turn window of stale handoff right after `/compact` runs (UserPromptSubmit fires before the next Stop), but the next assistant turn refreshes it.
- **Session-id reliance.** Binding uses the `CLAUDE_CODE_SESSION_ID` runtime variable. If a future Claude Code stops exposing it, the skill degrades to an in-memory survey (no warm handoff), which is safe but less complete.

## License

Expand Down
21 changes: 19 additions & 2 deletions hooks/update-handoff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,18 @@ fi
# handoff fields, and write JSON. Always exits 0; errors logged to stderr.
# Python stderr is propagated so deliberate diagnostic messages (e.g. atomic-replace failure) reach the test harness; fail-open via || true.
"$PY" - "$TRANSCRIPT_NATIVE" "$HANDOFF_NATIVE" "$SAFE_SID" "$CWD" "$TRANSCRIPT_PATH" <<'PYEOF' || true
import sys, os, json, datetime
import sys, os, json, datetime, re

_NOISE_RE = re.compile(
r'(?:^|[\\/])\.git[\\/]' # .git/ internals
r'|(?:^|[\\/])\.claude[\\/]plugins[\\/](?:data|cache)[\\/]', # plugin data/cache (rel + abs)
re.IGNORECASE)
def _is_noise_path(p):
return isinstance(p, str) and bool(_NOISE_RE.search(p))

_INJECTION_PREFIXES = ('<task-notification>', '[SYSTEM NOTIFICATION', 'Base directory for this skill:')
def _is_injected(text):
return isinstance(text, str) and text.lstrip().startswith(_INJECTION_PREFIXES)

TAIL_BYTES = 1_048_576
MAX_LINE_BYTES = 1_048_576
Expand Down Expand Up @@ -142,7 +153,7 @@ def content_blocks(msg):
# Extract recent_files (Tier A then B). Walk newest -> oldest.
recent_files_seen = []
def add_path(p):
if isinstance(p, str) and p and p not in recent_files_seen:
if isinstance(p, str) and p and p not in recent_files_seen and not _is_noise_path(p):
recent_files_seen.append(p)

TIER_A_TOOLS = {'Read', 'Edit', 'Write', 'NotebookEdit'}
Expand Down Expand Up @@ -263,6 +274,8 @@ for entry in reversed(parsed):
if not text_chunks:
continue
combined = '\n'.join(text_chunks)
if _is_injected(combined):
continue
if len(combined) > USER_REQUESTS_MAX_CHARS:
continue # oversized single message — skip, keep walking for shorter ones
if total_chars + len(combined) > USER_REQUESTS_MAX_CHARS:
Expand Down Expand Up @@ -346,6 +359,8 @@ for p in prior_cumulative + list(reversed(recent_files)):
seen.add(p)
merged_cumulative.append(p)

merged_cumulative = [p for p in merged_cumulative if not _is_noise_path(p)]

CUMULATIVE_CAP = 200
if len(merged_cumulative) > CUMULATIVE_CAP:
merged_cumulative = merged_cumulative[-CUMULATIVE_CAP:]
Expand All @@ -372,6 +387,8 @@ total_quote_chars = 0
for q in user_requests + prior_user_requests:
if not isinstance(q, str) or not q:
continue
if _is_injected(q):
continue
if q in seen_quotes:
continue
if len(q) > USER_REQUESTS_MAX_CHARS:
Expand Down
21 changes: 12 additions & 9 deletions skills/prep-compact/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ When invoked, read the warm handoff file maintained by the Stop hook, then perfo

## 1. Discovery

Locate the warm handoff file:
Resolve THIS session's own handoff via the helper — never by newest-modified file.

1. List `${CLAUDE_PLUGIN_DATA}/handoff-*.json` (resolve `${CLAUDE_PLUGIN_DATA}` from the environment; if unset, fall back to `~/.claude/cache/`).
2. Parse each JSON.
3. Filter to entries whose `cwd` matches the current working directory.
4. Of those, pick the one with the highest `mtime` (newest write).
1. Run `bash "<skill-base>/resolve-handoff.sh" "$PWD"`, where `<skill-base>` is this skill's base directory (substitute the literal path from the `Base directory for this skill:` line you were given at load — there is no `$SKILL_BASE` variable; a literal `$SKILL_BASE` would resolve to `/resolve-handoff.sh` and break this) and `$PWD` is the current directory.
2. Read the helper's first stdout line:
- `HIT` → the second line is the handoff file's absolute path. Read that JSON and use its extractive fields (§2), then run the §3 analytical pass.
- `MISS` → no handoff matched this session (not written yet, or you changed directories). Treat all extractive sources as empty (`cumulative_files=[]`, `in_progress=[]` with status `unknown`, `recent_task_launches=[]`, `recent_user_requests=[]`) and run §3 against the live conversation alone. Prefix the output: "Note: no handoff matched this session; surveyed from in-memory conversation."
- `NOSID` → no session id available. Same in-memory survey as `MISS`. Prefix: "Note: session id unavailable; surveyed from in-memory conversation."
- any other or empty output → treat as `MISS`.

If no matching handoff is found, treat all extractive sources as empty: `cumulative_files=[]`, `in_progress=[]` with status `unknown`, `recent_task_launches=[]`, `recent_user_requests=[]`. Then run §3 analytical pass against the in-memory conversation alone — it produces the same mini-schema output, just sourced entirely from the live transcript instead of from the warm handoff. Prefix the output with: "Note: no warm handoff matched cwd; surveyed from in-memory conversation."
The helper binds to the invoking session by `$CLAUDE_CODE_SESSION_ID` and validates the handoff's stored `cwd`, so a sibling session's handoff is never selected. If the session id is ever absent the helper returns `NOSID` and the skill degrades to the in-memory survey — safe, never cross-session.

## 2. Extractive fields — sourced from handoff JSON

Expand Down Expand Up @@ -46,13 +48,13 @@ Only claim what is observable. Omit rather than fabricate — if you do not know

## 4. Produce the /compact block — mini-schema

Default output: **single-line**, fields separated by ` | `. The post-compact resumer reads single-line and multi-line identically; single-line eliminates the "newline after `/compact`" failure mode. Field semantics: §2 (extractive), §3 (analytical).
Default output: **single-line**, fields separated by ` | `. Single-line eliminates the "newline after `/compact`" failure mode. Field semantics: §2 (extractive), §3 (analytical).

```
/compact goal: <one sentence> | next: <verb anchor — edit/run/inspect/ask/wait — concrete enough to execute without re-asking the user> | files: <minimum set needed to execute `next`, spec/plan first, code files after in relevance order> | decisions: decided=<key decisions with rationale>; constraints=<hard requirements + anti-patterns user stated>; blockers=<unresolved review/QA findings, failing tests, pending user answers> | state: changes=<uncommitted files>; tests=<passing/failing/unknown>; verify=<shortest rerunnable command, e.g. "bash test/run-tests.sh" — omit if none>; in_progress=<mid-implementation markers>; agents=<"agent <id>: wait|ignore|close" per running agent, or "none">
```

CRITICAL: `/compact` and `goal:` must be on the same line, separated by a single space. A newline directly after `/compact` makes Claude Code fire the bare command and drop the instructions. Subslots may be omitted when empty; write `none` only when silence would be ambiguous. Multi-line form is permitted for pre-paste inspection (split fields after `goal:` onto their own lines); the same-line rule for `/compact goal:` still applies.
CRITICAL: `/compact` and `goal:` must be on the same line, separated by a single space. A newline directly after `/compact` makes Claude Code fire the bare command and drop the instructions. Subslots may be omitted when empty; write `none` only when silence would be ambiguous. Single-line is the ONLY permitted form: the entire command is one physical line with no line breaks anywhere — never split fields onto separate lines.

**Compression:** preserve verbatim paths, identifiers, decisions, constraints, blockers, agent-IDs. Drop chitchat, transient tool output, and exploratory dead ends that were not acted on — but keep error text or dead ends that underpin a current blocker or decision. If length presses, reference the plan/spec file path and omit redundant file enumerations rather than inlining everything.

Expand All @@ -63,5 +65,6 @@ Output a preamble, the §4 schema with values filled in inside a single fenced c
- Preamble: "Compaction prep ready. Copy and run:"
- Fenced block body: §4's single-line schema with values filled in (literal first line must begin `/compact goal: ...`)
- Closing: "After compact, I'll re-read the files in `files:` and resume from `next:`."
- **Verify before presenting (required):** confirm the command (a) is a single physical line, (b) begins with the literal characters `/compact goal:`, and (c) contains no newline anywhere. If any check fails, rewrite it as one line before emitting.

If you used the fallback path (no warm handoff matched), prefix the output with: "Note: no warm handoff matched cwd; surveyed from in-memory conversation."
If you used the in-memory fallback (`MISS` or `NOSID` from §1), keep the exact prefix §1 specifies for that case.
135 changes: 135 additions & 0 deletions skills/prep-compact/resolve-handoff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Helper for the prep-compact skill. Resolves the INVOKING session's own
# handoff JSON via $CLAUDE_CODE_SESSION_ID and validates the stored cwd against
# the current cwd ($1). Prints exactly one of:
# HIT\n<abs-path> | MISS | NOSID
# Always exits 0 (fail-open), mirroring hooks/update-handoff.sh. Spec:
# docs/superpowers/specs/2026-06-05-prep-compact-session-binding-design.md
set -uo pipefail

CUR_CWD="${1:-$PWD}"

if command -v python3 >/dev/null 2>&1; then PY=python3
elif command -v python >/dev/null 2>&1 && python -c 'import sys; sys.exit(0 if sys.version_info[0]>=3 else 1)' 2>/dev/null; then PY=python
else
# No Python 3: cannot sanitize sid / parse JSON. Fail open.
if [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then echo MISS; else echo NOSID; fi
exit 0
fi

# Normalize filesystem roots to a Python-openable native form. Git Bash hands a
# native Windows Python MSYS paths (/c/..) it cannot stat; cygpath -m yields
# C:/Users/.. (forward slashes — safe in Python, no backslash-escape pitfalls).
# No-op off Windows (cygpath absent; paths already native).
_nrm() { if command -v cygpath >/dev/null 2>&1; then cygpath -m "$1" 2>/dev/null || printf '%s' "$1"; else printf '%s' "$1"; fi; }
PLUGIN_DATA_N=""; [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && PLUGIN_DATA_N="$(_nrm "$CLAUDE_PLUGIN_DATA")"

SID="${CLAUDE_CODE_SESSION_ID:-}" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore session binding without relying on missing env

When /prep-compact runs this helper from the skill, there is no hook JSON on stdin and Claude Code does not export the hook session_id as CLAUDE_CODE_SESSION_ID; the session id is only provided in hook input. In that normal invocation path SID is empty, the resolver prints NOSID, and the skill always falls back to the in-memory survey instead of reading the warm handoff the Stop hook just wrote, effectively disabling the core cumulative handoff behavior for users unless they happened to set this nonstandard env var themselves.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is wrong, CLAUDE_CODE_SESSION_ID is exposed, it just is not documented (yet)

CUR_CWD="$CUR_CWD" \
PLUGIN_DATA="$PLUGIN_DATA_N" \
PLUGIN_ROOT="$(_nrm "${CLAUDE_CODE_PLUGIN_CACHE_DIR:-${HOME:-}/.claude/plugins}")" \
CACHE_FALLBACK="$(_nrm "${HOME:-}/.claude/cache")" \
"$PY" - <<'PYEOF'
import os, re, sys, glob, hashlib, json, subprocess

sid = os.environ.get("SID", "")
cur_cwd = os.environ.get("CUR_CWD", "")
plugin_data = os.environ.get("PLUGIN_DATA", "")
plugin_root = os.environ.get("PLUGIN_ROOT", "")
cache_fallback = os.environ.get("CACHE_FALLBACK", "")

# Fallback when cygpath is absent but Python is native Windows: map MSYS /c/.. ->
# C:/.. so roots are stat-able. (_nrm already handles the cygpath-present case,
# incl. /tmp.) No-op on POSIX (os.name != 'nt') and where paths are already native.
def _fs(path):
if not path: return path
m = re.match(r'^/(?:cygdrive/)?([A-Za-z])/(.*)$', path)
if m and os.name == 'nt':
return m.group(1).upper() + ":/" + m.group(2)
return path
plugin_data = _fs(plugin_data)
plugin_root = _fs(plugin_root)
cache_fallback = _fs(cache_fallback)

# safe_sid — identical rule to the hooks.
if sid and re.fullmatch(r'[A-Za-z0-9_-]{1,64}', sid):
safe = sid
elif sid:
safe = hashlib.sha1(sid.encode('utf-8')).hexdigest()
else:
safe = ''
if not safe:
print("NOSID"); sys.exit(0)

fname = "handoff-%s.json" % safe

# Roots in priority order (list index = priority).
roots = []
if plugin_data:
roots.append(plugin_data)
roots.extend(sorted(glob.glob(os.path.join(glob.escape(plugin_root), "data", "*"))))
if cache_fallback:
roots.append(cache_fallback)

candidates = [] # (priority_index, path)
for i, root in enumerate(roots):
p = os.path.join(root, fname)
if os.path.isfile(p):
candidates.append((i, p))
if not candidates:
print("MISS"); sys.exit(0)

def _have_cygpath():
try:
subprocess.run(["cygpath", "--version"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=True); return True
except Exception:
return False
_CYG = _have_cygpath()

def _to_win(path):
if _CYG:
try:
o = subprocess.run(["cygpath", "-w", path], capture_output=True,
text=True, check=True).stdout.strip()
if o: return o
except Exception:
pass
m = re.match(r'^/(?:cygdrive/)?([A-Za-z])/(.*)$', path)
if m:
return m.group(1).upper() + ":\\" + m.group(2).replace('/', '\\')
return path

def canon(path):
if not path: return ""
p = _to_win(path)
if re.match(r'^[A-Za-z]:', p): # Windows drive path
return p.replace('/', '\\').rstrip('\\').lower()
return p.rstrip('/') # POSIX path, case-sensitive

target = canon(cur_cwd)

kept = [] # (priority_index, path)
for prio, path in candidates:
try:
with open(path, 'r', encoding='utf-8') as f:
d = json.load(f)
if not isinstance(d, dict):
continue
stored = d.get('cwd', '')
if not isinstance(stored, str):
continue
if canon(stored) == target:
kept.append((prio, path))
except Exception:
continue # any malformed/odd candidate: skip, keep scanning
if not kept:
print("MISS"); sys.exit(0)

kept.sort(key=lambda t: (t[0], t[1])) # priority, then lexical
chosen = kept[0][1]
print("HIT")
print(_to_win(chosen))
sys.exit(0)
PYEOF
exit 0
Loading
Loading