
Specstory Sync
- 2 installs
- Updated August 2, 2026
- anian0/pick-skills
specstory-sync is a Claude skill that configures automatic Claude Code conversation-history export to Markdown via a Stop-event hook.
About
This skill configures automatic conversation-history recording for a project, Specstory-style. It deploys a sync_to_spec.py script that reads Claude Code JSONL session data from ~/.claude/projects/ and converts each session into formatted Markdown under .specstory/history/, triggered by a Claude Code Stop-event hook. A developer uses it to review, search, or archive past AI conversations, or to add that capability to a project. It handles Python-interpreter selection, fingerprint deduplication, .gitignore setup, and uninstall.
- Auto-records Claude Code conversation history to Markdown on every reply
- Wires a Stop-event hook in .claude/settings.json to a sync_to_spec.py script
- Deduplicates via a fingerprint mechanism and is Specstory-format compatible
Specstory Sync by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,839 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
specstory-sync capabilities & compatibility
Free; a local Python script and a Claude Code hook, no API keys stated.
- Capabilities
- memory · documentation
- Use cases
- memory · documentation · orchestration
- Platforms
- macOS · Linux · Windows
- Pricing
- Free
What specstory-sync says it does
为任意项目一键配置对话历史自动记录。
**Hook**:利用 Claude Code 的 `Stop` 事件钩子自动触发
**去重**:通过 fingerprint 机制避免重复写入
npx skills add https://github.com/anian0/pick-skills --skill specstory-syncAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| Last updated | August 2, 2026 |
| Repository | anian0/pick-skills ↗ |
What it does
Auto-export Claude Code conversation history to Markdown via a Stop hook so past AI sessions can be reviewed, searched, or archived.
Who is it for?
Automatically saving, searching, and archiving Claude Code conversation history as Markdown files.
Skip if: Managing conversation history for non-Claude-Code agents without JSONL sessions.
When should I use this skill?
The user mentions conversation records, session history, specstory, auto-saving or exporting conversations, or wants to review past AI conversations.
What you get
Timestamped Markdown transcripts written to .specstory/history/ automatically after each reply, with duplicates suppressed.
- a deployed sync_to_spec.py script
- a Stop hook in .claude/settings.json
- Markdown transcripts in .specstory/history/
By the numbers
- 4 setup steps
- requires Python >= 3.10
Files
Specstory Sync — Claude Code 对话历史自动记录
为任意项目一键配置对话历史自动记录。每次 Claude Code 完成回复时,自动将当前会话导出为格式化的 Markdown 文件,存放在项目的 .specstory/history/ 目录下。
工作原理
1. 脚本 sync_to_spec.py 读取 ~/.claude/projects/ 下的 JSONL 会话数据 2. 转换为 Specstory 兼容的 Markdown(含时间戳、角色标签、工具调用摘要) 3. 去重:通过 fingerprint 机制避免重复写入 4. Hook:利用 Claude Code 的 Stop 事件钩子自动触发
执行步骤
当用户要求配置对话历史记录时,按以下步骤操作:
Step 1: 部署脚本
将 scripts/sync_to_spec.py 复制到目标项目的 .specstory/ 目录:
# 确保目标目录存在
mkdir -p <project-root>/.specstory/history
# 复制脚本
cp <skill-dir>/scripts/sync_to_spec.py <project-root>/.specstory/sync_to_spec.py其中 <skill-dir> 是本 skill 所在目录,<project-root> 是用户当前项目的根目录。
Step 2: 配置 Stop Hook
读取项目已有的 .claude/settings.json(如果不存在则创建),在其中添加 Stop 事件钩子。
重要:必须保留已有的 hooks 和其他配置,只合并新增内容。
需要添加的 Hook 配置:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": ".venv/Scripts/python.exe .specstory/sync_to_spec.py",
"timeout": 15,
"statusMessage": "Syncing conversation history..."
}
]
}
]
}
}Python 解释器适配:根据项目环境选择正确的 Python 路径:
- 如果项目有
.venv/Scripts/python.exe(Windows 虚拟环境),使用.venv/Scripts/python.exe - 如果项目有
.venv/bin/python(Linux/Mac 虚拟环境),使用.venv/bin/python - 如果项目有
pyproject.toml且使用 uv,使用uv run python - 否则使用系统
python3(Mac/Linux)或python(Windows)
Step 3: 验证安装
运行脚本测试一次,确认能正确读取和转换对话数据:
cd <project-root>
<python-path> .specstory/sync_to_spec.py成功输出类似:
[specstory-sync] Synced 47 messages -> 2026-04-22_03-34-25Z-some-title.md如果出现错误,检查:
- Python 版本 >= 3.10(使用了
Path | None类型语法) ~/.claude/projects/下是否有对应项目的目录- Windows 路径编码是否正确(
D:\foo→D--foo)
Step 4: 确认 .gitignore
建议在项目的 .gitignore 中添加以下条目(如果用户希望将对话历史纳入版本控制则跳过):
.specstory/history/输出格式
生成的 Markdown 文件存放在 .specstory/history/ 下,命名规则:
YYYY-MM-DD_HH-MM-SSZ-<title-slug>.md文件内容示例:
<!-- Generated by Claude Code Specstory Sync -->
# 2026-04-22 03:34
<!-- Claude Code Session abc123-def456 (2026-04-22T03:34:25.907Z) -->
_**User (2026-04-22 03:34:25)**_
帮我实现一个功能...
---
_**Assistant (2026-04-22 03:34:30)**_
好的,我来帮你实现...
*Read*: `src/main.py`
pip install requests
---已有 Specstory 目录的处理
如果项目已经存在 .specstory/ 目录(来自 Cursor/VSCode 的 Specstory 扩展),本脚本会复用该目录,生成的文件与原有格式兼容。两种来源的记录可以共存。
卸载
如果用户想要移除此功能:
1. 从 .claude/settings.json 中删除 Stop hook 条目 2. 删除 .specstory/sync_to_spec.py 3. (可选)删除 .specstory/history/ 下的 .*.fp fingerprint 文件 4. (可选)删除生成的 Markdown 文件
"""Sync Claude Code conversation history to Specstory-style Markdown files.
Reads the current session's JSONL data from ~/.claude/projects/ and converts
it into formatted Markdown stored in .specstory/history/.
Designed to be called via Claude Code's Stop hook.
"""
import json
import os
import sys
import time
import re
from datetime import datetime, timezone
from pathlib import Path
def find_project_dir(cwd: str) -> Path | None:
"""Map a working directory to its .claude/projects/ subdirectory name.
Claude Code encodes paths by replacing : and \\ with -, then joining
segments with -. e.g. D:\\workspace\\foo -> D--workspace-foo
"""
normalized = cwd.replace("\\", "/").rstrip("/")
normalized = normalized.replace(":", "-")
parts = normalized.split("/")
encoded = "-".join(parts)
projects_root = Path.home() / ".claude" / "projects"
candidate = projects_root / encoded
if candidate.exists():
return candidate
for d in projects_root.iterdir():
if d.is_dir() and d.name.lower() == encoded.lower():
return d
return None
def find_active_session(project_dir: Path) -> Path | None:
"""Find the most recently modified session JSONL in the project dir."""
jsonl_files = list(project_dir.glob("*.jsonl"))
if not jsonl_files:
return None
return max(jsonl_files, key=lambda f: f.stat().st_mtime)
def read_jsonl(path: Path) -> list[dict]:
"""Read a JSONL file, skipping malformed lines."""
records = []
for attempt in range(3):
try:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError:
pass
return records
except (OSError, PermissionError):
if attempt < 2:
time.sleep(0.5)
else:
raise
return records
def extract_messages(records: list[dict]) -> list[dict]:
"""Extract user and assistant messages from JSONL records, in order."""
messages = []
for rec in records:
if rec.get("isSidechain"):
continue
msg_type = rec.get("type")
if msg_type not in ("user", "assistant"):
continue
ts = rec.get("timestamp", "")
msg = rec.get("message", {})
role = msg.get("role", msg_type)
content = msg.get("content", "")
messages.append({
"role": role,
"content": content,
"timestamp": ts,
})
return messages
def format_content(content) -> str:
"""Format a message content field (string or list) into Markdown."""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = []
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type", "")
if btype == "tool_result":
raw = block.get("content", "")
if isinstance(raw, list):
text = "\n".join(
item.get("text", str(item)) if isinstance(item, dict) else str(item)
for item in raw
)
else:
text = str(raw)
if len(text) > 500:
text = text[:500] + "\n... (truncated)"
if text.strip():
parts.append(f"<tool-result>\n{text.strip()}\n</tool-result>")
elif btype == "text":
text = block.get("text", "")
if text.strip():
parts.append(text.strip())
elif btype == "tool_use":
name = block.get("name", "")
inp = block.get("input", {})
if name == "Bash" and "command" in inp:
cmd = inp["command"]
if len(cmd) > 200:
cmd = cmd[:200] + "..."
parts.append(f"```bash\n{cmd}\n```")
elif name in ("Read", "Write", "Edit"):
fp = inp.get("file_path", inp.get("filePath", ""))
parts.append(f"*{name}*: `{fp}`")
else:
inp_str = json.dumps(inp, ensure_ascii=False)
if len(inp_str) > 200:
inp_str = inp_str[:200] + "..."
parts.append(f"*{name}*: {inp_str}")
return "\n\n".join(parts)
return str(content).strip()
def render_markdown(messages: list[dict], session_id: str, project_name: str) -> str:
"""Render messages into a Specstory-compatible Markdown document."""
if not messages:
return ""
first_ts = messages[0].get("timestamp", "")
try:
dt = datetime.fromisoformat(first_ts.replace("Z", "+00:00"))
date_str = dt.strftime("%Y-%m-%d %H:%M")
file_ts = dt.strftime("%Y-%m-%d_%H-%M-%SZ")
except (ValueError, AttributeError):
date_str = first_ts
file_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%SZ")
lines = [
"<!-- Generated by Claude Code Specstory Sync -->",
"",
f"# {date_str}",
"",
f"<!-- Claude Code Session {session_id} ({first_ts}) -->",
"",
]
for msg in messages:
role = msg["role"]
ts = msg.get("timestamp", "")
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
ts_display = dt.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, AttributeError):
ts_display = ts
content = format_content(msg["content"])
if not content:
continue
if role == "user":
lines.append(f"_**User ({ts_display})**_")
lines.append("")
lines.append(content)
elif role == "assistant":
lines.append(f"_**Assistant ({ts_display})**_")
lines.append("")
lines.append(content)
lines.append("")
lines.append("---")
lines.append("")
return "\n".join(lines), file_ts
def compute_message_fingerprint(messages: list[dict]) -> str:
"""Create a lightweight fingerprint of the message sequence for dedup."""
parts = []
for m in messages:
role = m["role"]
ts = m.get("timestamp", "")
content = m["content"]
if isinstance(content, str):
snippet = content[:100]
elif isinstance(content, list):
snippet = str(len(content))
else:
snippet = ""
parts.append(f"{role}|{ts}|{snippet}")
return str(hash("|".join(parts)))
def sync(cwd: str):
"""Main sync function."""
project_dir = find_project_dir(cwd)
if project_dir is None:
print(f"[specstory-sync] No .claude/projects/ directory found for: {cwd}", file=sys.stderr)
return
session_path = find_active_session(project_dir)
if session_path is None:
print(f"[specstory-sync] No session JSONL found in {project_dir}", file=sys.stderr)
return
session_id = session_path.stem
records = read_jsonl(session_path)
messages = extract_messages(records)
if not messages:
return
specstory_dir = Path(cwd) / ".specstory" / "history"
specstory_dir.mkdir(parents=True, exist_ok=True)
fingerprint_file = specstory_dir / f".{session_id}.fp"
new_fp = compute_message_fingerprint(messages)
if fingerprint_file.exists():
old_fp = fingerprint_file.read_text(encoding="utf-8").strip()
if old_fp == new_fp:
return
project_name = Path(cwd).name
result = render_markdown(messages, session_id, project_name)
if result is None:
return
md_content, file_ts = result
existing = list(specstory_dir.glob(f"*{session_id[:8]}*"))
if existing:
output_path = existing[0]
else:
_skip_prefixes = ("<local-command-", "<command-", "Base directory for this skill")
title = ""
for m in messages:
if m["role"] != "user":
continue
text = ""
if isinstance(m["content"], str):
text = m["content"].strip()
elif isinstance(m["content"], list):
for block in m["content"]:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "").strip()
break
if not text or any(text.startswith(p) for p in _skip_prefixes):
continue
title = text[:60]
title = re.sub(r'[\s/\\:*?"<>|]+', '-', title)
title = re.sub(r'[^\w\-.]', '', title.encode('ascii', 'ignore').decode('ascii'))
title = title.strip('-')[:40]
break
suffix = f"-{title}" if title else ""
output_path = specstory_dir / f"{file_ts}{suffix}.md"
output_path.write_text(md_content, encoding="utf-8")
fingerprint_file.write_text(new_fp, encoding="utf-8")
print(f"[specstory-sync] Synced {len(messages)} messages -> {output_path.name}")
if __name__ == "__main__":
cwd = os.environ.get("CLAUDE_PROJECT_CWD", os.environ.get("CLAUDE_CWD", os.getcwd()))
sync(cwd)
Related skills
FAQ
How is the export triggered?
Via a Claude Code Stop-event hook in .claude/settings.json that runs sync_to_spec.py after each reply.
How does it avoid duplicate writes?
It uses a fingerprint mechanism to deduplicate sessions before writing.