Parsing the session logs Claude Code warns you not to parse
Shipped
Ghostwriter drafts LinkedIn posts in your voice. Before this release, the flow opened by interviewing you: two or three generic questions to pin down a topic. Version 0.6.0 throws that out for a two-tier idea menu instead. You pick a lane (an industry radar, a recent personal project, or an evergreen angle), then pick a concrete anchor inside that lane, and the pick becomes the post’s real subject. The radar also widened from Anthropic-only to the whole AI industry. The interesting engineering is the personal-project lane: a new scripts/recent_projects.py finds local repos you’ve worked in lately by reading Claude Code’s own session logs. That is worth a walkthrough on its own, because Claude Code’s documentation explicitly tells you not to do this.
The warning you’re about to ignore
Claude Code writes every session to a JSONL transcript under ~/.claude/projects/<project>/<session-id>.jsonl, where <project> is your working directory with every non-alphanumeric character swapped for a dash. The docs are blunt about what comes next: “The entry format is internal to Claude Code and changes between versions, so scripts that parse these files directly can break on any release. To build on session data, use /export or the script interfaces instead.” The sanctioned interfaces they point to (/export, claude -p --resume, a hook’s transcript_path, the Agent SDK) all assume you already know which session you want. None of them answer “which repos have I been active in lately,” because that question requires scanning across sessions you haven’t picked yet. There is no supported way to do that today. So building a discovery tool means parsing the logs directly, against explicit advice, which only makes sense if you parse them the way you’d treat any format that could shift under you: read the one field you need, and tolerate everything else.
Read the one field you can trust
JSONL, or JSON Lines, is the right format for this kind of file precisely because each line is a complete, independent JSON value: a reader can process one line at a time and a broken line at the end doesn’t corrupt the ones before it. That property is what makes tolerant parsing possible. Out of everything in a transcript line, the one field worth trusting is cwd, the working directory Claude Code recorded when the session started. Read it defensively: open the file, walk it line by line, skip anything that doesn’t parse, and stop at the first cwd you find.
# recent_repos.py -- find real repos with recent Claude Code sessions
from __future__ import annotations
import json
import subprocess
from pathlib import Path
SESSIONS = Path.home() / ".claude" / "projects"
def session_cwd(jsonl: Path) -> str | None:
"""Return the authoritative working directory recorded in a transcript."""
try:
with jsonl.open(encoding="utf-8", errors="replace") as f:
for line in f:
if '"cwd"' not in line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue # tolerate a partial or corrupted line
cwd = obj.get("cwd")
if cwd:
return cwd
except OSError:
return None
return None
This is the robustness principle applied to a file format instead of a network protocol: be liberal in what you accept, because the thing producing this data owes you no compatibility guarantee. Read the narrowest slice you can get away with, and let a malformed or reshaped line degrade to “skip it” instead of a stack trace.
Don’t trust the directory name
It’s tempting to skip parsing entirely and just decode the slug in the directory name back into a path. Don’t; the docs already told you the substitution is “non-alphanumeric characters replaced by -,” and that substitution is lossy. A dash and a dot both become the same dash, so two different real paths can produce an identical slug:
def slugify(cwd: str) -> str:
return "".join(c if c.isalnum() else "-" for c in cwd)
assert slugify("/Users/nate/my-app") == slugify("/Users/nate/my.app")
# both become "-Users-nate-my-app" -- the slug alone can't tell them apart
Two unrelated projects can collide on the same session directory name. The cwd field inside each transcript is the only place the real, unambiguous path lives, which is why session_cwd reads content, never the filename.
Discover, dedupe, and skip the noise
A repo usually has many sessions, so collect the newest transcript per real path (not per slug), keep only directories that still exist and still look like git repos, and skip anything under a temp path. Scratch directories used for one-off subagent runs will otherwise show up as “recent work” even though nothing there is worth posting about:
SKIP_PREFIXES = ("/private/tmp", "/private/var/folders", "/var/folders", "/tmp")
def recent_repos(limit: int = 5) -> list[dict]:
newest: dict[str, float] = {}
if not SESSIONS.is_dir():
return []
for jsonl in SESSIONS.rglob("*.jsonl"):
cwd = session_cwd(jsonl)
if not cwd or cwd.startswith(SKIP_PREFIXES):
continue
path = Path(cwd)
if not path.is_dir() or not (path / ".git").is_dir():
continue # real, still-existing repos only
mtime = jsonl.stat().st_mtime
newest[cwd] = max(newest.get(cwd, 0), mtime) # dedupe by real path
ranked = sorted(newest, key=lambda c: newest[c], reverse=True)[:limit]
out = []
for cwd in ranked:
last = subprocess.run(
["git", "-C", cwd, "log", "-1", "--format=%s"],
capture_output=True, text=True,
).stdout.strip()
out.append({"name": Path(cwd).name, "last_commit": last or "(no commits)"})
return out
The last-commit lookup is what turns a bare path into something you’d pick from a menu: not “here’s a directory,” but “here’s the thing you shipped there.”
Use it, and watch it degrade instead of crash
Run it against your own ~/.claude/projects and you get real repos with real last commits, ranked by how recently you touched them:
python3 recent_repos.py
natejswenson.io: feat(devlog): register local-budget + stage v0.1.0 entry
claude-skills: docs(devlog): resolve em-dash template collision + allow flagged output trims
budget: refactor: rebrand from Wells Fargo-specific to generic bank statements (#6)
local-fitness: feat(agent): redesign brief PDF to fit one page, more polished look
ollama: fix: correct bash-category glob regression across all agents, finish resume agent
Now prove the defensive parsing holds up. Feed session_cwd a transcript with a blank line, a line with no cwd, and a truncated final line, and confirm it still finds the real value instead of throwing:
def test_tolerates_bad_lines(tmp_path):
f = tmp_path / "s.jsonl"
f.write_text('\n{"type":"meta"}\n{"cwd":"/home/n/app"}\n{"broken',
encoding="utf-8")
assert session_cwd(f) == "/home/n/app"
That test passes today. When a Claude Code update reshapes the transcript, the failure mode you want is this test going red in your own suite, not the discovery step silently returning an empty list in front of a user with no explanation.
Gotchas
The format can move without warning, and there’s nowhere to check first. The docs say the entry format changes between versions but don’t publish a changelog for it, so you find out by your parser going quiet. Treat that as a design constraint from the start: read one field, wrap the read in a broad except, and make “found nothing” the worst-case outcome, never an exception that reaches the user.
Decoding the slug back into a path looks like it works, until two repos collide. Anything named with a dash and anything named with a dot in the same position slugify to the same directory. If you ever find yourself doing slug.replace("-", "/") to reconstruct a path, stop; read cwd from the transcript content instead, every time.
Scratch and subagent directories pollute the discovery list if you don’t filter them. Sessions started from a temp working directory, or from an internal subagent scratch path, get logged the same as any real repo. Skip known temp prefixes explicitly rather than assuming every session directory represents something worth surfacing.
Sources
- Manage sessions — Claude Code Docs — transcripts are JSONL under
~/.claude/projects/<project>/, the project slug replaces non-alphanumeric characters with-, and the entry format is internal and can change between versions. - JSON Lines — the one-JSON-value-per-line format, and why it’s safe to read line by line even when the file is truncated.
- Robustness principle — Wikipedia — be liberal in what you accept, conservative in what you produce; the design rule behind tolerant parsing of a format you don’t control.
Changelog
- ghostwriter 0.6.0: idea-menu entry point + industry-wide radar (87fca1f)