Design — /recover: rebuild a crashed Claude Code session from disk

Status:approved-by-user
Date:2026-06-17
Branch:master
Type:spec

Goal

Add a manually-invoked /recover skill that, when the user opens Claude in a repo whose previous session crashed (computer died, terminal closed, Claude broke mid-task) and the session ID is unknown, finds the most-recently-active prior session for that folder, reconstructs its working state from the raw on-disk transcript, and injects a budgeted digest into the current session so work continues right where it left off. It also surfaces the native claude --resume <id> command for a true thread continuation.

Why this exists (and why not the built-ins)

Claude Code already ships claude --continue/-c (resume the most recent session in the cwd) and claude --resume (interactive picker). Those are excellent — but they have two gaps /recover fills:

The neighboring jacked skills stay in their lanes. The dividing line is the source of truth and the precondition:

SkillPreconditionSource of truthMechanism
retryTransient API/rate-limit blip, mid-turnLive context window (session still alive)Prompt only — no file I/O
redoApproach went sideways, want a clean restartgit + intentStash + rebranch (throws work away)
checkpointA /checkpoint was deliberately run earlierSelf-authored .claude/checkpoints/*.htmlReload the saved artifact
jackedWant a specific past session by topic, any machineQdrant semantic index over all sessionsSearch → retrieve → inject
recover (new)Session crashed before any save; ID unknownRaw ~/.claude/projects/<slug>/*.jsonl for THIS cwdNewest prior transcript → reconstruct → inject

Architecture

Three components with clean boundaries. The hard parts (JSONL parsing, repo-path mapping, noise-stripping, summary extraction) already exist in transcript.py and config.py — both verified Qdrant-free (stdlib imports only). The new logic is a thin, unit-testable layer plus a Click subcommand plus a Markdown skill.

flowchart TD
    A[User runs /recover] --> B["jacked recover --json --exclude $CLAUDE_CODE_SESSION_ID"]
    B --> C[recover.py: resolve project dir for cwd]
    C --> D[Rank candidates by last timestamped line; exclude live session]
    D --> E{Any candidates?}
    E -->|no| F[Tell user: no prior session for this folder — stop]
    E -->|yes| G[Skill shows auto-pick: title · last prompt · when · branch + alternates]
    G --> H{User confirms pick?}
    H -->|wrong one| I[User names an alternate]
    I --> J
    H -->|yes| J["jacked recover --session ID --digest"]
    J --> K[recover.py build_digest: budgeted, noise-stripped, TodoWrite, incomplete-turn flag]
    K --> L[Digest printed into current context = injection + print 'claude --resume ID']
    L --> M[Re-anchor: you were doing X, next step was Y — ready to continue]
  
flowchart TD
    A[User runs /recover] --> B["jacked recover --json --exclude $CLAUDE_CODE_SESSION_ID"]
    B --> C[recover.py: resolve project dir for cwd]
    C --> D[Rank candidates by last timestamped line; exclude live session]
    D --> E{Any candidates?}
    E -->|no| F[Tell user: no prior session for this folder — stop]
    E -->|yes| G[Skill shows auto-pick: title, last prompt, when, branch + alternates]
    G --> H{User confirms pick?}
    H -->|wrong one| I[User names an alternate]
    I --> J
    H -->|yes| J["jacked recover --session ID --digest"]
    J --> K[recover.py build_digest: budgeted, noise-stripped, TodoWrite, incomplete-turn flag]
    K --> L[Digest printed into current context = injection + print 'claude --resume ID']
    L --> M[Re-anchor: you were doing X, next step was Y — ready to continue]
  

recover.py public functions

FunctionResponsibility
resolve_project_dir(cwd, projects_root=None) -> Path | NoneMap this folder → its ~/.claude/projects/<slug> dir. Try config.get_session_dir_for_repo(); verify by reading the cwd field inside the dir's newest .jsonl; if mismatch or missing, fall back to enumerating projects_root/* and matching each folder's first cwd field against the real cwd. Defeats the dot-slug bug (jack.neiljack-neil) and stale dot-preserving folders.
list_candidates(project_dir, exclude_session_id, now) -> list[SessionCandidate]List flat *.jsonl (main sessions; subagent transcripts live in <uuid>/subagents/ and are skipped). Exclude exclude_session_id. Rank by last timestamped line (the literal last lines are untimestamped metadata). Tolerate a half-written final line. Each candidate carries session_id, path, ai_title, last_prompt, last_ts, git_branch, msg_count, truncated.
build_digest(session_path, budget=DEFAULT) -> DigestReuse parse_jsonl_file_enriched() + the noise-strippers. Extract the working state (see Digest section). Respect budget; when truncating, emit a visible note pointing to claude --resume <id> — never a silent drop.
resume_command(session_id) -> strReturn claude --resume <session_id>. Trivial; reimplemented here rather than importing the Qdrant-bound retriever.get_resume_command.

Session resolution & live-session exclusion

Two subtleties, both verified empirically against real files on this machine, drive correctness here.

The path slug eats dots

A cwd maps to a projects folder by replacing both / and . with -. /Users/jack.neil/Github/claude-jacked-Users-jack-neil-Github-claude-jacked. A naïve /-only slug misses it, and a stale dot-preserving folder from an older build also exists. Resolution therefore never trusts the slug alone — it verifies (or discovers) the folder by reading the cwd field stored inside the transcripts.

Excluding the currently-running session

Robust exclusion strategy, in order:

  1. Skill passes --exclude "$CLAUDE_CODE_SESSION_ID" at the shell level. This captures the value where it's known to be set, independent of the Python subprocess environment — the primary mechanism.
  2. CLI fallback: if --exclude is empty, read CLAUDE_CODE_SESSION_ID, then CLAUDE_SESSION_ID, from the environment.
  3. Belt-and-suspenders heuristic: also drop any candidate whose last timestamped line is within ~60s of now and whose tail looks live (ends in fresh assistant/system traffic rather than the untimestamped last-prompt/ai-title/mode/permission-mode tail a finished session leaves). This guards the rare case where the env var is unavailable.

The digest: what gets recovered

There is no summary line type in the transcript format — recap state lives in lightweight metadata lines. build_digest walks the chosen transcript (skipping isSidechain subagent branches) and assembles, in priority order:

CLI surface

# Phase 1 — rank candidates (cheap metadata, machine-readable)
jacked recover --json [--cwd PATH] [--exclude SESSION_ID] [--limit N]

# Phase 2 — build the digest for the confirmed session (the injection payload)
jacked recover --session <id> --digest [--budget N] [--cwd PATH]
FlagMeaning
--jsonEmit ranked candidates as JSON: {chosen, candidates:[…]}, each with id/title/last_prompt/last_ts/branch/msg_count/age/truncated. Default phase-1 output.
--session <id>Target a specific session (the auto-pick, or an alternate the user named).
--digestPrint the budgeted working-state digest for --session + the claude --resume <id> line.
--exclude <id>Session to exclude (the live one). Skill passes "$CLAUDE_CODE_SESSION_ID"; CLI falls back to env if empty.
--cwd PATHOverride the working directory to resolve (default: process cwd).
--limit NHow many candidates to return in phase 1 (default 3).
--budget NDigest size cap (chars or lines). Generous default; truncation is logged in-output.

The /recover skill flow

A thin SKILL.md (frontmatter: name: recover + a trigger-tight description anchored on "session/computer crashed mid-work," "didn't run /checkpoint," "session ID unknown," "rebuild this folder's last session from the transcript" — and explicitly not for transient API errors, deliberately-saved state, or topic search across old projects). The body:

  1. Run jacked recover --json --exclude "$CLAUDE_CODE_SESSION_ID".
  2. No candidates → tell the user no prior session was found for this folder (fresh repo / wrong folder / nothing crashed here) and stop. Don't fabricate.
  3. Present the auto-pick — title · last prompt · when · branch — plus the alternates list. Confirm before injecting.
  4. On confirm → jacked recover --session <id> --digest; that output is the injected working state. Then surface claude --resume <id> as "for a true native continuation (preserves Claude's internal state), run this in a fresh terminal."
  5. Re-anchor in one or two lines: "You were doing X; the next step was Y," nodding to MEMORY.md for standing project conventions, and continue the work.
  6. Wrong pick → the user names an alternate from the list → re-call with --session <alt>.

File changes

FileChange
jacked/recover.pyNew. The four public functions above + SessionCandidate/Digest dataclasses. Qdrant-free; imports transcript + config only.
jacked/cli.pyAdd the recover Click subcommand (phase-1 --json + phase-2 --digest). Not _require_search-gated. Tolerant error output (never a stack trace at the user).
jacked/data/skills/recover/SKILL.mdNew. The thin orchestrator skill. Installs via the existing jacked install skills glob.
tests/test_recover.pyNew. Unit tests over recover.py with JSONL fixtures (run with uv run python -m pytest).
jacked/__init__.pyVersion bump.
README.mdOne-paragraph mention of /recover under the skills/commands section.

Testing

Python (TDD, pytest)

Skill (writing-skills RED → GREEN)

This is a technique skill, so test by application. RED: give a fresh agent "my session in this repo crashed, recover it" without the skill and document the flailing (won't know the env var to exclude, won't handle the dot-slug, may reach for Qdrant/jacked, may dump the whole transcript). GREEN: with the skill, the agent runs the two-phase CLI, confirms the pick, injects a budgeted digest, and offers claude --resume. REFACTOR: close any gaps surfaced.

Scope boundaries (YAGNI)

Failure modes

ScenarioBehavior
No project folder resolves for this cwdReport "no recorded sessions for this folder" and stop. Suggest checking the folder is right.
Folder exists but only the live session is presentAfter excluding the live id, zero candidates → "nothing to recover here yet."
CLAUDE_CODE_SESSION_ID unavailableFall back to env names, then the live-tail heuristic; if still ambiguous, present candidates and let the user exclude the current one explicitly.
Multiple very-recent candidatesAuto-pick the newest, but the skill shows the alternates and confirms before injecting (never silently grabs).
Crash-truncated final lineParser skips/repairs the bad last line; digest flags "last turn may be incomplete."
Chosen transcript exceeds budgetDigest is truncated with a visible note + the claude --resume <id> pointer to the full thread.
jacked CLI not on PATH (skill running where the package isn't installed)Skill detects the failure and tells the user to install/repair jacked, rather than half-recovering.

Open questions


Generated with the jacked HTML artifact template.