release: v1.38.0 with continue-claude-work and skill-creator enhancements

## New Skill: continue-claude-work (v1.1.0)
- Recover actionable context from local `.claude` session artifacts
- Compact-boundary-aware extraction (reads Claude's own compaction summaries)
- Subagent workflow recovery (reports completed vs interrupted subagents)
- Session end reason detection (clean exit, interrupted, error cascade, abandoned)
- Size-adaptive strategy for small/large sessions
- Noise filtering (skips 37-53% of session lines)
- Self-session exclusion, stale index fallback, MEMORY.md integration
- Bundled Python script (no external dependencies)
- Security scan passed, argument-hint added

## Skill Updates
- **skill-creator** (v1.5.0): Complete rewrite with evaluation framework
  - Added agents/ (analyzer, comparator, grader)
  - Added eval-viewer/ (generate_review.py, viewer.html)
  - Added scripts/ (run_eval, aggregate_benchmark, improve_description, run_loop)
  - Added references/schemas.md (eval/benchmark schemas)
  - Expanded SKILL.md with inline vs fork guidance, progressive disclosure patterns
  - Enhanced package_skill.py and quick_validate.py

- **transcript-fixer** (v1.2.0): CLI improvements and test coverage
  - Enhanced argument_parser.py and commands.py
  - Added correction_service.py improvements
  - Added test_correction_service.py

- **tunnel-doctor** (v1.4.0): Quick diagnostic script
  - Added scripts/quick_diagnose.py
  - Enhanced SKILL.md with 5-layer conflict model

- **pdf-creator** (v1.1.0): Auto DYLD_LIBRARY_PATH + rendering fixes
  - Auto-detect and set DYLD_LIBRARY_PATH for weasyprint
  - Fixed list rendering and CSS improvements

- **github-contributor** (v1.0.3): Enhanced project evaluation
  - Added evidence-loop, redaction, and merge-ready PR guidance

## Documentation
- Updated marketplace.json (v1.38.0, 42 skills)
- Updated CHANGELOG.md with v1.38.0 entry
- Updated CLAUDE.md (skill count, marketplace version, #42 description)
- Updated README.md (badges, skill section #42, use case, requirements)
- Updated README.zh-CN.md (badges, skill section #42, use case, requirements)
- Fixed absolute paths in continue-claude-work/references/file_structure.md

## Validation
- All skills passed quick_validate.py
- continue-claude-work passed security_scan.py
- marketplace.json validated (valid JSON)
- Cross-checked version consistency across all docs
This commit is contained in:
daymade
2026-03-07 14:54:33 +08:00
parent b675ac6fee
commit c49e23e7ef
35 changed files with 7349 additions and 297 deletions

View File

@@ -0,0 +1,4 @@
Security scan passed
Scanned at: 2026-03-07T14:27:12.638956
Tool: gitleaks + pattern-based validation
Content hash: c464aa735e8b7832c2c77e4cea22fff9c7e6117ecee4f6769f5eb62cced8a11a

View File

@@ -0,0 +1,141 @@
---
name: continue-claude-work
description: Recover actionable context from local `.claude` session artifacts and continue interrupted work without running `claude --resume`. This skill should be used when the user provides a Claude session ID, asks to continue prior work from local history, or wants to inspect `.claude` files before resuming implementation.
argument-hint: [session-id]
---
# Continue Claude Work
## Overview
Recover actionable context from a prior Claude Code session and continue execution in the current conversation. Use local session files as the source of truth, then continue with concrete edits and checks — not just summarizing.
**Why this exists instead of `claude --resume`**: `claude --resume` replays the full session transcript into the context window. For long sessions this wastes tokens on resolved issues and stale state. This skill **selectively reconstructs** only actionable context — the latest compact summary, pending work, known errors, and current workspace state — giving a fresh start with prior knowledge.
## File Structure Reference
For directory layout, JSONL schemas, and compaction block format, see `references/file_structure.md`.
## Workflow
### Step 1: Extract Context (single script call)
Run the bundled extraction script. It handles session discovery, compact-boundary parsing, noise filtering, and workspace state in one call:
```bash
# Latest session for current project
python3 scripts/extract_resume_context.py
# Specific session by ID
python3 scripts/extract_resume_context.py --session <SESSION_ID>
# Search by topic
python3 scripts/extract_resume_context.py --query "auth feature"
# List recent sessions
python3 scripts/extract_resume_context.py --list
```
The script outputs a structured Markdown **briefing** containing:
- **Session metadata** from `sessions-index.json`
- **Compact summary** — Claude's own distilled summary from the last compaction boundary (highest-signal context)
- **Last user requests** — the most recent explicit asks
- **Last assistant responses** — what was claimed done
- **Errors encountered** — tool failures and error outputs
- **Unresolved tool calls** — indicates interrupted session
- **Subagent workflow state** — which subagents completed, which were interrupted, their last outputs
- **Session end reason** — clean exit, interrupted (ctrl-c), error cascade, or abandoned
- **Files touched** — files created/edited/read during the session
- **MEMORY.md** — persistent cross-session notes
- **Git state** — current status, branch, recent log
The script automatically skips the currently active session (modified < 60s ago) to avoid self-extraction.
### Step 2: Branch by Session End Reason
The briefing includes a **Session end reason**. Use it to choose the right continuation strategy:
| End Reason | Strategy |
|-----------|----------|
| **Clean exit** | Session completed normally. Read the last user request that was addressed. Continue from pending work if any. |
| **Interrupted** | Tool calls were dispatched but never got results (likely ctrl-c or timeout). Retry the interrupted tool calls or assess whether they are still needed. |
| **Error cascade** | Multiple API errors caused the session to fail. Do not retry blindly — diagnose the root cause first. |
| **Abandoned** | User sent a message but got no response. Treat the last user message as the current request. |
If the briefing has a **Subagent Workflow** section with interrupted subagents, check what each was doing and whether to retry or skip.
### Step 3: Reconcile and Continue
Before making changes:
1. Confirm the current directory matches the session's project.
2. If the git branch has changed from the session's branch, note this and decide whether to switch.
3. Inspect files related to pending work — verify old claims still hold.
4. Do not assume old claims are valid without checking.
Then:
- Implement the next concrete step aligned with the latest user request.
- Run deterministic verification (tests, type-checks, build).
- If blocked, state the exact blocker and propose one next action.
### Step 4: Report
Respond concisely:
- **Context recovered**: which session, key findings from the briefing
- **Work executed**: files changed, commands run, test results
- **Remaining**: pending tasks, if any
## How the Script Works
### Compact-Boundary-Aware Extraction
The script finds the **last** compact boundary in the session JSONL and extracts its summary. This is the single highest-signal piece of context in any long session -- Claude's own distilled understanding of the entire conversation up to that point. For details on compaction format and JSONL schemas, see `references/file_structure.md`.
### Size-Adaptive Strategy
| Session size | Strategy |
|-------------|----------|
| Has compactions | Read last compact summary + all post-compact messages |
| < 500 KB, no compactions | Read last 60% of messages |
| 500 KB - 5 MB | Read last 30% of messages |
| > 5 MB | Read last 15% of messages |
### Subagent Context Extraction
When a session has subagent directories (`<session-id>/subagents/`), the script parses each subagent's JSONL to extract agent type, completion status, and last text output. This enables recovery of multi-agent workflows -- e.g., if a 32-subagent evaluation pipeline was interrupted, the briefing shows which agents completed and which need retry.
### Session End Reason Detection
The script classifies how the session ended:
- **completed** -- assistant had the last word (clean exit)
- **interrupted** -- unresolved tool calls (ctrl-c or timeout)
- **error_cascade** -- 3+ API errors
- **abandoned** -- user sent a message with no response
### Noise Filtering
These message types are skipped (37-53% of lines in real sessions):
- `progress`, `queue-operation`, `file-history-snapshot` -- operational noise
- `api_error`, `turn_duration`, `stop_hook_summary` -- system subtypes
- `<task-notification>`, `<system-reminder>` -- filtered from user text extraction
## Guardrails
- Do not run `claude --resume` or `claude --continue` — this skill provides context recovery within the current session.
- Do not treat compact summaries as complete truth — they are lossy. Always verify claims against current workspace.
- Do not overwrite unrelated working-tree changes.
- Do not load the full session file into context — always use the script.
## Limitations
- Cannot recover sessions whose `.jsonl` files have been deleted from `~/.claude/projects/`.
- Cannot access sessions from other machines (files are local only).
- Edit tool operations show deltas, not full file content — use `claude-code-history-files-finder` for full file recovery.
- Compact summaries are lossy — early conversation details may be missing.
- `sessions-index.json` can be stale (entries pointing to deleted files). The script falls back to filesystem-based discovery.
## Example Trigger Phrases
- "continue work from session `abc123-...`"
- "don't resume, just read the .claude files and continue"
- "check what I was working on in the last session and keep going"
- "search my sessions for the PR review work"

View File

@@ -0,0 +1,249 @@
# Claude Code Local File Structure
Ground-truth reference for `~/.claude/` directory layout and JSONL session format.
## Directory Layout
```
~/.claude/
projects/ # Per-project session storage (primary)
<normalized-path>/
sessions-index.json # Master index of all sessions
<session-id>.jsonl # Session transcript
<session-id>/ # Session subdirectory (optional)
subagents/
agent-<agent-id>.meta.json # Agent metadata
agent-<agent-id>.jsonl # Agent transcript
tool-results/
toolu_<tool-id>.txt # Large tool outputs
memory/ # Persistent memory files (MEMORY.md, etc.)
history.jsonl # Global prompt history (no session IDs)
tasks/ # Task tracking (per-session lock/highwatermark)
plans/ # Plan documents (random-name.md)
debug/ # Per-session debug logs (<session-id>.txt)
transcripts/ # Global tool operation logs (ses_<id>.jsonl)
file-history/ # File modification backups
todos/ # Todo items
```
## Path Normalization
Project paths are encoded by replacing `/` with `-`:
| Original | Normalized |
|----------|-----------|
| `/path/to/project` | `-path-to-project` |
| `/another/workspace/app` | `-another-workspace-app` |
## sessions-index.json Schema
```json
{
"version": 1,
"entries": [
{
"sessionId": "20089b2a-e3dd-48b8-809c-0647128bf3b8",
"fullPath": "~/.claude/projects/-path-to-project/20089b2a-....jsonl",
"fileMtime": 1741327503477,
"firstPrompt": "fix the login bug",
"summary": "Fixed authentication redirect...",
"messageCount": 42,
"created": "2026-03-07T03:25:03.477Z",
"modified": "2026-03-07T12:21:43.806Z",
"gitBranch": "main",
"projectPath": "/path/to/project",
"isSidechain": false
}
],
"originalPath": "/path/to/project"
}
```
Key fields for session identification:
- `sessionId` — UUID v4 format
- `firstPrompt` — first user message (best for topic matching)
- `summary` — auto-generated summary of the session
- `modified` — last activity timestamp (ISO 8601)
- `gitBranch` — git branch at session time
- `isSidechain``false` for main conversations
## Compaction in Session Files
Claude Code uses [server-side compaction](https://platform.claude.com/docs/en/build-with-claude/compaction). When context fills up, two consecutive lines appear:
### Line 1: compact_boundary marker
```json
{
"type": "system",
"subtype": "compact_boundary",
"parentUuid": null,
"logicalParentUuid": "prev-uuid",
"compactMetadata": {
"trigger": "input_tokens",
"preTokens": 180000
}
}
```
### Line 2: Compact summary (special user message)
```json
{
"type": "user",
"isCompactSummary": true,
"isVisibleInTranscriptOnly": true,
"message": {
"role": "user",
"content": "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\nAnalysis:\n1. **Initial Request**: User asked to...\n2. **Progress**: Completed X, Y, Z...\n3. **Current state**: Working on..."
}
}
```
Key properties:
- **`isCompactSummary: true`** — most reliable way to identify compact summaries
- **`isVisibleInTranscriptOnly: true`** — not sent to the API, only stored in the transcript
- Summary is always a plain string in `.message.content` (not an array)
- Typically 12K-31K characters (high-density information)
- A long session may have multiple compact boundaries (4+ is common for 10MB+ sessions)
- The **last** compact boundary's summary reflects the most recent state
- Messages after the last boundary are the "hot zone" — they were in Claude's live context
### Default compaction prompt (from API docs)
> "You have written a partial transcript for the initial task above. Please write a summary of the transcript. The purpose of this summary is to provide continuity so you can continue to make progress towards solving the task in a future context, where the raw history above may not be accessible and will be replaced with this summary."
## Session JSONL Message Types
Each `.jsonl` file has one JSON object per line. Common types:
### file-history-snapshot (always first line)
```json
{
"type": "file-history-snapshot",
"messageId": "uuid",
"snapshot": { "trackedFileBackups": {}, "timestamp": "..." },
"isSnapshotUpdate": false
}
```
### User message
```json
{
"parentUuid": "prev-uuid or null",
"isSidechain": false,
"cwd": "/path/to/project",
"sessionId": "session-uuid",
"version": "2.1.71",
"gitBranch": "main",
"type": "user",
"message": {
"role": "user",
"content": "fix the login bug"
},
"uuid": "msg-uuid",
"timestamp": "2026-03-07T03:25:03.477Z"
}
```
**Important**: `.message.content` can be:
- A **string** for plain text user messages
- An **array** of content blocks for tool results and multi-part messages:
```json
"content": [
{ "type": "tool_result", "tool_use_id": "toolu_...", "content": "..." },
{ "type": "text", "text": "now do X" }
]
```
### Assistant message
```json
{
"type": "assistant",
"message": {
"role": "assistant",
"model": "claude-opus-4-6",
"content": [
{ "type": "thinking", "thinking": "internal reasoning..." },
{ "type": "text", "text": "visible response text" },
{ "type": "tool_use", "id": "toolu_...", "name": "Bash", "input": { "command": "..." } }
]
}
}
```
Content block types in assistant messages:
- `thinking` — internal reasoning (skip when extracting actionable context)
- `text` — visible response to user (extract this)
- `tool_use` — tool invocations (useful for understanding what was done)
### Tool result (user message with tool output)
```json
{
"type": "user",
"message": {
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "toolu_...", "content": "command output..." }
]
}
}
```
## history.jsonl Schema
Global prompt log. Does NOT contain session IDs — only useful for finding when a prompt was issued and in which project:
```json
{
"display": "/init ",
"pastedContents": {},
"timestamp": 1758996122446,
"project": "/path/to/project"
}
```
## Extraction Patterns
### List recent sessions for current project
```bash
cat ~/.claude/projects/<normalized-path>/sessions-index.json \
| jq '.entries | sort_by(.modified) | reverse | .[:5] | .[] | {sessionId, firstPrompt, summary, messageCount, modified, gitBranch}'
```
### Extract user text from session tail
Handles both string and array content formats:
```bash
tail -n 200 <session-file>.jsonl \
| jq -r 'select(.type == "user" and .message.role == "user")
| .message.content
| if type == "string" then .
elif type == "array" then map(select(.type == "text") | .text) | join("\n")
else empty end' \
| tail -n 80
```
### Extract assistant text (excluding thinking/tool_use)
```bash
tail -n 200 <session-file>.jsonl \
| jq -r 'select(.message.role == "assistant")
| .message.content
| if type == "array" then map(select(.type == "text") | .text) | join("\n")
else empty end' \
| tail -n 120
```
### Find sessions by keyword in firstPrompt
```bash
cat ~/.claude/projects/<normalized-path>/sessions-index.json \
| jq -r '.entries[] | select(.firstPrompt | test("keyword"; "i")) | "\(.modified) \(.sessionId) \(.firstPrompt)"'
```

View File

@@ -0,0 +1,837 @@
#!/usr/bin/env python3
"""
Extract actionable resume context from Claude Code session files.
Produces a structured Markdown briefing by fusing:
- Session index metadata (sessions-index.json)
- Compact boundary summaries (highest-signal context)
- Post-compact user/assistant messages (the "hot zone")
- Subagent workflow state (multi-agent recovery)
- Session end reason detection
- Git workspace state
- MEMORY.md persistent context
- Interrupted tool-call detection
Usage:
# Extract context from latest session for current project
python3 extract_resume_context.py
# Extract context from a specific session
python3 extract_resume_context.py --session <SESSION_ID>
# Search sessions by topic
python3 extract_resume_context.py --query "auth feature"
# List recent sessions
python3 extract_resume_context.py --list
# Specify project path explicitly
python3 extract_resume_context.py --project /path/to/project
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
CLAUDE_DIR = Path.home() / ".claude"
PROJECTS_DIR = CLAUDE_DIR / "projects"
# Message types that are noise — skip when extracting context
NOISE_TYPES = {"progress", "queue-operation", "file-history-snapshot", "last-prompt"}
# System message subtypes that are noise
NOISE_SUBTYPES = {"api_error", "turn_duration", "stop_hook_summary"}
# Patterns that indicate system/internal content, not real user requests
NOISE_USER_PATTERNS = [
"This session is being continued",
"<task-notification>",
"<system-reminder>",
]
def normalize_path(project_path: str) -> str:
"""Convert absolute path to Claude's normalized directory name."""
return project_path.replace("/", "-")
def find_project_dir(project_path: str) -> Path | None:
"""Find the Claude projects directory for a given project path."""
abs_path = os.path.abspath(project_path)
# If the path is already inside ~/.claude/projects/, use it directly
projects_str = str(PROJECTS_DIR) + "/"
if abs_path.startswith(projects_str):
candidate = Path(abs_path)
if candidate.is_dir():
return candidate
rel = abs_path[len(projects_str):]
top_dir = PROJECTS_DIR / rel.split("/")[0]
if top_dir.is_dir():
return top_dir
normalized = normalize_path(abs_path)
candidate = PROJECTS_DIR / normalized
if candidate.is_dir():
return candidate
# Fallback: search for partial match
for d in PROJECTS_DIR.iterdir():
if d.is_dir() and normalized in d.name:
return d
return None
def load_sessions_index(project_dir: Path) -> list[dict]:
"""Load and parse sessions-index.json, sorted by modified desc."""
index_file = project_dir / "sessions-index.json"
if not index_file.exists():
return []
with open(index_file, encoding="utf-8") as f:
data = json.load(f)
entries = data.get("entries", [])
entries.sort(key=lambda e: e.get("modified", ""), reverse=True)
return entries
def search_sessions(entries: list[dict], query: str) -> list[dict]:
"""Search sessions by keyword in firstPrompt and summary."""
query_lower = query.lower()
results = []
for entry in entries:
first_prompt = (entry.get("firstPrompt") or "").lower()
summary = (entry.get("summary") or "").lower()
if query_lower in first_prompt or query_lower in summary:
results.append(entry)
return results
def format_session_entry(entry: dict, file_exists: bool = True) -> str:
"""Format a session index entry for display."""
sid = entry.get("sessionId", "?")
modified = entry.get("modified", "?")
msgs = entry.get("messageCount", "?")
branch = entry.get("gitBranch", "?")
prompt = (entry.get("firstPrompt") or "")[:80]
ghost = "" if file_exists else " [file missing]"
return f" {sid} [{branch}] {msgs} msgs {modified}{ghost}\n {prompt}"
# ── Session file parsing ────────────────────────────────────────────
def parse_session_structure(session_file: Path) -> dict:
"""Parse a session JSONL file and return structured data."""
file_size = session_file.stat().st_size
total_lines = 0
# First pass: find compact boundaries and count lines
compact_boundaries = [] # (line_num, summary_text)
with open(session_file, encoding="utf-8") as f:
prev_boundary_line = None
for i, raw_line in enumerate(f):
total_lines += 1
# Detect compact summary via isCompactSummary flag (most reliable)
if '"isCompactSummary"' in raw_line:
try:
obj = json.loads(raw_line)
if obj.get("isCompactSummary"):
content = obj.get("message", {}).get("content", "")
if isinstance(content, str):
boundary_line = prev_boundary_line if prev_boundary_line is not None else max(0, i - 1)
compact_boundaries.append((boundary_line, content))
prev_boundary_line = None
continue
except json.JSONDecodeError:
pass
# Detect compact_boundary marker
if '"compact_boundary"' in raw_line and '"subtype"' in raw_line:
try:
obj = json.loads(raw_line)
if obj.get("subtype") == "compact_boundary":
prev_boundary_line = i
continue
except json.JSONDecodeError:
pass
# Fallback: if prev line was boundary and this is a user message with long string content
if prev_boundary_line is not None:
try:
obj = json.loads(raw_line)
content = obj.get("message", {}).get("content", "")
if isinstance(content, str) and len(content) > 100:
compact_boundaries.append((prev_boundary_line, content))
except (json.JSONDecodeError, AttributeError):
compact_boundaries.append((prev_boundary_line, ""))
prev_boundary_line = None
# Determine hot zone: everything after last compact boundary + its summary
if compact_boundaries:
last_boundary_line = compact_boundaries[-1][0]
hot_zone_start = last_boundary_line + 2 # skip boundary + summary
else:
# No compact boundaries: use size-adaptive strategy
if file_size < 500_000: # <500KB: read last 60%
hot_zone_start = max(0, int(total_lines * 0.4))
elif file_size < 5_000_000: # <5MB: read last 30%
hot_zone_start = max(0, int(total_lines * 0.7))
else: # >5MB: read last 15%
hot_zone_start = max(0, int(total_lines * 0.85))
# Second pass: extract hot zone messages
messages = []
unresolved_tool_calls = {} # tool_use_id -> tool_use_info
errors = []
files_touched = set()
last_message_role = None
error_count = 0
with open(session_file, encoding="utf-8") as f:
for i, raw_line in enumerate(f):
if i < hot_zone_start:
continue
try:
obj = json.loads(raw_line)
except json.JSONDecodeError:
continue
msg_type = obj.get("type", "")
if msg_type in NOISE_TYPES:
continue
if msg_type == "system":
subtype = obj.get("subtype", "")
if subtype in NOISE_SUBTYPES:
if subtype == "api_error":
error_count += 1
continue
if subtype == "compact_boundary":
continue
# Track tool calls and results
msg = obj.get("message", {})
role = msg.get("role", "")
content = msg.get("content", "")
# Extract tool_use from assistant messages
if role == "assistant" and isinstance(content, list):
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_use":
continue
tool_id = block.get("id", "")
tool_name = block.get("name", "?")
inp = block.get("input", {})
unresolved_tool_calls[tool_id] = {
"name": tool_name,
"input_preview": str(inp)[:200],
}
# Track file operations
if tool_name in ("Write", "Edit", "Read"):
fp = inp.get("file_path", "")
if fp:
files_touched.add(fp)
elif tool_name == "Bash":
cmd = inp.get("command", "")
for match in re.findall(r'(?<!\w)(/[a-zA-Z][\w./\-]+)', cmd):
if not match.startswith("/dev/"):
files_touched.add(match)
# Resolve tool results
if role == "user" and isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_result":
tool_id = block.get("tool_use_id", "")
unresolved_tool_calls.pop(tool_id, None)
is_error = block.get("is_error", False)
result_content = block.get("content", "")
if is_error and isinstance(result_content, str):
errors.append(result_content[:500])
# Track last message for end-reason detection
if role in ("user", "assistant"):
last_message_role = role
messages.append(obj)
# Detect session end reason
end_reason = _detect_end_reason(
last_message_role, unresolved_tool_calls, error_count,
)
return {
"total_lines": total_lines,
"file_size": file_size,
"compact_boundaries": compact_boundaries,
"hot_zone_start": hot_zone_start,
"messages": messages,
"unresolved_tool_calls": dict(unresolved_tool_calls),
"errors": errors,
"error_count": error_count,
"files_touched": files_touched,
"end_reason": end_reason,
}
def _detect_end_reason(
last_role: str | None,
unresolved: dict,
error_count: int,
) -> str:
"""Detect why the session ended."""
if unresolved:
return "interrupted" # Tool calls dispatched but no results — likely ctrl-c
if error_count >= 3:
return "error_cascade" # Multiple API errors suggest systemic failure
if last_role == "assistant":
return "completed" # Assistant had the last word — clean end
if last_role == "user":
return "abandoned" # User sent a message but got no response
return "unknown"
def _is_noise_user_text(text: str) -> bool:
"""Check if user text is system noise rather than a real request."""
for pattern in NOISE_USER_PATTERNS:
if text.startswith(pattern) or pattern in text[:200]:
return True
return False
def extract_user_text(messages: list[dict], limit: int = 5) -> list[str]:
"""Extract the last N user text messages (not tool results or system noise)."""
user_texts = []
for msg_obj in reversed(messages):
if msg_obj.get("isCompactSummary"):
continue
msg = msg_obj.get("message", {})
if msg.get("role") != "user":
continue
content = msg.get("content", "")
if isinstance(content, str) and content.strip():
if _is_noise_user_text(content):
continue
user_texts.append(content.strip())
elif isinstance(content, list):
texts = [
b.get("text", "")
for b in content
if isinstance(b, dict) and b.get("type") == "text"
]
combined = "\n".join(t for t in texts if t.strip())
if combined and not _is_noise_user_text(combined):
user_texts.append(combined)
if len(user_texts) >= limit:
break
user_texts.reverse()
return user_texts
def extract_assistant_text(messages: list[dict], limit: int = 3) -> list[str]:
"""Extract the last N assistant text responses (no thinking/tool_use)."""
assistant_texts = []
for msg_obj in reversed(messages):
msg = msg_obj.get("message", {})
if msg.get("role") != "assistant":
continue
content = msg.get("content", "")
if isinstance(content, list):
texts = [
b.get("text", "")
for b in content
if isinstance(b, dict) and b.get("type") == "text"
]
combined = "\n".join(t for t in texts if t.strip())
if combined:
assistant_texts.append(combined[:2000])
if len(assistant_texts) >= limit:
break
assistant_texts.reverse()
return assistant_texts
# ── Subagent extraction ──────────────────────────────────────────────
def extract_subagent_context(session_file: Path) -> list[dict]:
"""Extract subagent summaries from session subdirectories.
Returns list of {name, type, status, last_text, is_interrupted}.
"""
session_dir = session_file.parent / session_file.stem
subagents_dir = session_dir / "subagents"
if not subagents_dir.is_dir():
return []
# Group by agent ID: find meta.json and .jsonl pairs
agent_ids = set()
for f in subagents_dir.iterdir():
if f.suffix == ".jsonl":
agent_ids.add(f.stem)
results = []
for agent_id in sorted(agent_ids):
jsonl_file = subagents_dir / f"{agent_id}.jsonl"
meta_file = subagents_dir / f"{agent_id}.meta.json"
# Parse agent type from ID (format: agent-a<type>-<hash> or agent-a<hash>)
agent_type = "unknown"
if meta_file.exists():
try:
with open(meta_file, encoding="utf-8") as f:
meta = json.load(f)
agent_type = meta.get("type", meta.get("subagent_type", "unknown"))
except (json.JSONDecodeError, OSError):
pass
if agent_type == "unknown":
# Infer from ID pattern: agent-a<type>-<hash>
match = re.match(r'agent-a(compact|prompt_suggestion|[a-z_]+)-', agent_id)
if match:
agent_type = match.group(1)
# Skip compact and prompt_suggestion agents (internal, not user work)
if agent_type in ("compact", "prompt_suggestion"):
continue
# Read last few lines for final output
last_text = ""
is_interrupted = False
line_count = 0
if jsonl_file.exists():
try:
lines = jsonl_file.read_text(encoding="utf-8").strip().split("\n")
line_count = len(lines)
has_tool_use_pending = False
# Check last 10 lines for final assistant text
for raw_line in reversed(lines[-10:]):
try:
obj = json.loads(raw_line)
msg = obj.get("message", {})
role = msg.get("role", "")
content = msg.get("content", "")
if role == "assistant" and isinstance(content, list):
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if block_type == "tool_use":
has_tool_use_pending = True
elif block_type == "text":
text = block.get("text", "")
if text.strip() and not last_text:
last_text = text.strip()[:500]
if role == "user" and isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_result":
has_tool_use_pending = False
except json.JSONDecodeError:
continue
is_interrupted = has_tool_use_pending
except OSError:
pass
results.append({
"id": agent_id,
"type": agent_type,
"last_text": last_text,
"is_interrupted": is_interrupted,
"lines": line_count,
})
return results
# ── Context sources ──────────────────────────────────────────────────
def get_git_state(project_path: str) -> str:
"""Get current git status and recent log."""
parts = []
try:
branch = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, cwd=project_path, timeout=5,
)
if branch.stdout.strip():
parts.append(f"**Current branch**: `{branch.stdout.strip()}`")
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
try:
status = subprocess.run(
["git", "status", "--short"],
capture_output=True, text=True, cwd=project_path, timeout=10,
)
if status.stdout.strip():
parts.append(f"### git status\n```\n{status.stdout.strip()}\n```")
else:
parts.append("### git status\nClean working tree.")
except (subprocess.TimeoutExpired, FileNotFoundError):
parts.append("### git status\n(unavailable)")
try:
log = subprocess.run(
["git", "log", "--oneline", "-5"],
capture_output=True, text=True, cwd=project_path, timeout=10,
)
if log.stdout.strip():
parts.append(f"### git log (last 5)\n```\n{log.stdout.strip()}\n```")
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return "\n\n".join(parts)
def get_memory_md(project_dir: Path) -> str | None:
"""Read MEMORY.md if it exists in the project's memory directory."""
memory_dir = project_dir / "memory"
memory_file = memory_dir / "MEMORY.md"
if memory_file.exists():
content = memory_file.read_text(encoding="utf-8").strip()
if content:
return content[:3000]
return None
def get_session_memory(session_file: Path) -> str | None:
"""Read session-memory/summary.md if it exists (newer CC versions)."""
session_dir = session_file.parent / session_file.stem
summary = session_dir / "session-memory" / "summary.md"
if summary.exists():
content = summary.read_text(encoding="utf-8").strip()
if content:
return content[:3000]
return None
# ── Output formatting ────────────────────────────────────────────────
END_REASON_LABELS = {
"completed": "Clean exit (assistant completed response)",
"interrupted": "Interrupted (unresolved tool calls — likely ctrl-c or timeout)",
"error_cascade": "Error cascade (multiple API errors)",
"abandoned": "Abandoned (user message with no response)",
"unknown": "Unknown",
}
def build_briefing(
session_entry: dict | None,
parsed: dict,
project_path: str,
project_dir: Path,
session_file: Path,
) -> str:
"""Build the structured Markdown briefing."""
sections = []
# Header
sections.append("# Resume Context Briefing\n")
# Session metadata
if session_entry:
sid = session_entry.get("sessionId", "?")
modified = session_entry.get("modified", "?")
branch = session_entry.get("gitBranch", "?")
msg_count = session_entry.get("messageCount", "?")
first_prompt = session_entry.get("firstPrompt", "")
summary = session_entry.get("summary", "")
sections.append("## Session Info\n")
sections.append(f"- **ID**: `{sid}`")
sections.append(f"- **Last active**: {modified}")
sections.append(f"- **Branch**: `{branch}`")
sections.append(f"- **Messages**: {msg_count}")
sections.append(f"- **First prompt**: {first_prompt}")
if summary:
sections.append(f"- **Summary**: {summary[:300]}")
# File stats + end reason
file_mb = parsed["file_size"] / 1_000_000
end_label = END_REASON_LABELS.get(parsed["end_reason"], parsed["end_reason"])
sections.append(f"\n**Session file**: {file_mb:.1f} MB, {parsed['total_lines']} lines, "
f"{len(parsed['compact_boundaries'])} compaction(s)")
sections.append(f"**Session end reason**: {end_label}")
if parsed["error_count"] > 0:
sections.append(f"**API errors**: {parsed['error_count']}")
# Session memory (newer CC versions generate this automatically)
session_mem = get_session_memory(session_file)
if session_mem:
sections.append("\n## Session Memory (auto-generated by Claude Code)\n")
sections.append(session_mem)
# Compact summary (highest-signal context)
if parsed["compact_boundaries"]:
last_summary = parsed["compact_boundaries"][-1][1]
if last_summary:
# Allow up to 8K chars — this is the highest-signal content
display = last_summary[:8000]
if len(last_summary) > 8000:
display += f"\n\n... (truncated, full summary: {len(last_summary)} chars)"
sections.append("\n## Compact Summary (auto-generated by previous session)\n")
sections.append(display)
# Last user requests
user_texts = extract_user_text(parsed["messages"])
if user_texts:
sections.append("\n## Last User Requests\n")
for i, text in enumerate(user_texts, 1):
display = text[:500]
if len(text) > 500:
display += "..."
sections.append(f"### Request {i}\n{display}\n")
# Last assistant responses
assistant_texts = extract_assistant_text(parsed["messages"])
if assistant_texts:
sections.append("\n## Last Assistant Responses\n")
for i, text in enumerate(assistant_texts, 1):
display = text[:1000]
if len(text) > 1000:
display += "..."
sections.append(f"### Response {i}\n{display}\n")
# Errors encountered
if parsed["errors"]:
sections.append("\n## Errors Encountered\n")
seen = set()
for err in parsed["errors"]:
short = err[:200]
if short not in seen:
seen.add(short)
sections.append(f"```\n{err}\n```\n")
# Unresolved tool calls (interrupted session)
if parsed["unresolved_tool_calls"]:
sections.append("\n## Unresolved Tool Calls (session was interrupted)\n")
for tool_id, info in parsed["unresolved_tool_calls"].items():
sections.append(f"- **{info['name']}**: `{tool_id}`")
sections.append(f" Input: {info['input_preview']}")
# Subagent context (the "nobody has done this" feature)
subagents = extract_subagent_context(session_file)
if subagents:
interrupted = [s for s in subagents if s["is_interrupted"]]
completed = [s for s in subagents if not s["is_interrupted"]]
sections.append(f"\n## Subagent Workflow ({len(completed)} completed, {len(interrupted)} interrupted)\n")
if interrupted:
sections.append("### Interrupted Subagents\n")
for sa in interrupted:
sections.append(f"- **{sa['type']}** (`{sa['id']}`, {sa['lines']} lines)")
if sa["last_text"]:
sections.append(f" Last output: {sa['last_text'][:300]}")
if completed:
sections.append("\n### Completed Subagents\n")
for sa in completed:
sections.append(f"- **{sa['type']}** (`{sa['id']}`, {sa['lines']} lines)")
if sa["last_text"]:
sections.append(f" Last output: {sa['last_text'][:200]}")
# Files touched in session
if parsed["files_touched"]:
sections.append("\n## Files Touched in Session\n")
for fp in sorted(parsed["files_touched"])[:30]:
sections.append(f"- `{fp}`")
# MEMORY.md
memory = get_memory_md(project_dir)
if memory:
sections.append("\n## Persistent Memory (MEMORY.md)\n")
sections.append(memory)
# Git state
sections.append("\n## Current Workspace State\n")
sections.append(get_git_state(project_path))
return "\n".join(sections)
# ── CLI ──────────────────────────────────────────────────────────────
def _check_session_files(entries: list[dict], project_dir: Path) -> dict[str, bool]:
"""Check which index entries have actual files on disk."""
status = {}
for entry in entries:
sid = entry.get("sessionId", "")
session_file = project_dir / f"{sid}.jsonl"
if session_file.exists():
status[sid] = True
else:
full_path = entry.get("fullPath", "")
status[sid] = bool(full_path and Path(full_path).exists())
return status
def main():
parser = argparse.ArgumentParser(
description="Extract actionable resume context from Claude Code sessions.",
)
parser.add_argument(
"--project", "-p",
default=os.getcwd(),
help="Project path (default: current directory)",
)
parser.add_argument(
"--session", "-s",
default=None,
help="Session ID to extract context from",
)
parser.add_argument(
"--query", "-q",
default=None,
help="Search sessions by keyword in firstPrompt/summary",
)
parser.add_argument(
"--list", "-l",
action="store_true",
help="List recent sessions",
)
parser.add_argument(
"--limit", "-n",
type=int,
default=10,
help="Number of sessions to list (default: 10)",
)
parser.add_argument(
"--exclude-current",
default=None,
help="Session ID to exclude (typically the currently active session)",
)
args = parser.parse_args()
project_path = os.path.abspath(args.project)
project_dir = find_project_dir(project_path)
if not project_dir:
print(f"Error: no Claude session data found for {project_path}", file=sys.stderr)
print(f"Looked in: {PROJECTS_DIR / normalize_path(project_path)}", file=sys.stderr)
sys.exit(1)
entries = load_sessions_index(project_dir)
# ── List mode ──
if args.list:
# Show both index entries and actual files, with file-exists status
file_status = _check_session_files(entries, project_dir)
existing = sum(1 for v in file_status.values() if v)
missing = sum(1 for v in file_status.values() if not v)
if not entries and not list(project_dir.glob("*.jsonl")):
print("No sessions found.")
sys.exit(0)
print(f"Sessions for {project_path}:\n")
if missing > 0:
print(f" (index has {len(entries)} entries, {existing} with files, {missing} with missing files)\n")
for entry in entries[:args.limit]:
sid = entry.get("sessionId", "")
exists = file_status.get(sid, False)
print(format_session_entry(entry, file_exists=exists))
print()
# Also show files NOT in index
indexed_ids = {e.get("sessionId") for e in entries}
orphan_files = [
f for f in sorted(project_dir.glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True)
if f.stem not in indexed_ids
]
if orphan_files:
print(f" Files not in index ({len(orphan_files)}):")
for f in orphan_files[:5]:
size_kb = f.stat().st_size / 1000
print(f" {f.stem} ({size_kb:.0f} KB)")
print()
sys.exit(0)
# ── Query mode ──
if args.query:
results = search_sessions(entries, args.query)
if not results:
print(f"No sessions matching '{args.query}'.", file=sys.stderr)
sys.exit(1)
print(f"Sessions matching '{args.query}' ({len(results)} found):\n")
for entry in results[: args.limit]:
print(format_session_entry(entry))
print()
if len(results) == 1:
args.session = results[0]["sessionId"]
else:
sys.exit(0)
# ── Extract mode ──
session_id = args.session
session_entry = None
if session_id:
for entry in entries:
if entry.get("sessionId") == session_id:
session_entry = entry
break
session_file = project_dir / f"{session_id}.jsonl"
if not session_file.exists():
if session_entry:
full_path = session_entry.get("fullPath", "")
if full_path and Path(full_path).exists():
session_file = Path(full_path)
if not session_file.exists():
for jsonl in project_dir.glob("*.jsonl"):
if session_id in jsonl.name:
session_file = jsonl
break
else:
print(f"Error: session file not found for {session_id}", file=sys.stderr)
sys.exit(1)
else:
# Use latest session — prefer actual files, skip current session
jsonl_files = sorted(
project_dir.glob("*.jsonl"),
key=lambda f: f.stat().st_mtime,
reverse=True,
)
if args.exclude_current:
jsonl_files = [f for f in jsonl_files if f.stem != args.exclude_current]
if not jsonl_files:
print("Error: no session files found.", file=sys.stderr)
sys.exit(1)
# Skip the most recent file if it's likely the current active session
# (modified within the last 60 seconds and no explicit session requested)
if len(jsonl_files) > 1:
newest = jsonl_files[0]
age_seconds = time.time() - newest.stat().st_mtime
if age_seconds < 60:
print(f"Skipping active session {newest.stem} (modified {age_seconds:.0f}s ago)",
file=sys.stderr)
jsonl_files = jsonl_files[1:]
session_file = jsonl_files[0]
session_id = session_file.stem
for entry in entries:
if entry.get("sessionId") == session_id:
session_entry = entry
break
# Parse and build briefing
print(f"Parsing session {session_id} ({session_file.stat().st_size / 1_000_000:.1f} MB)...",
file=sys.stderr)
parsed = parse_session_structure(session_file)
briefing = build_briefing(session_entry, parsed, project_path, project_dir, session_file)
print(briefing)
if __name__ == "__main__":
main()