The hardest part of case articles is gathering material — git log has only commit messages, the session details vanish. Four hooks plus two slash commands turn every dev session into raw.md, zero marginal cost.
The hardest part of writing a "Claude did X for me" case article isn't writing — it's gathering the material. The git log only has commit messages; the proposals you rejected, the failed attempts, the three revisions of that one prompt, the error output — that's what makes an article worth reading, and it's all gone.
Worse: by the time you write the article, you don't remember why you picked A over B. "I recall having a reason" vs. "that reason was X" is the difference between a credible article and filler.
Meeting notes cost more than the article itself, so you don't take them. The only fix is to let recording happen on its own — hooks.
I had Claude write 4 hooks + 2 slash commands that turn every dev session into docs/notes/<feature>/raw.md. The previous article, Letting Claude Deploy to Production, pulled most of its commits, bash snippets, and error references from this pipeline. So did this one.
4 hooks + 2 manual slash commands form one conveyor belt:
| Trigger | What it does |
|---|---|
| PostToolUse(Edit\ | Write\ |
| PostToolUse(Bash) | Stop when back on master + clean |
| git post-commit | Write a checkpoint per commit |
| Stop | Parse the transcript at session end |
/record-feature NAME /stop-recording |
Manual override |
One state file: docs/notes/.state.json. Every hook reads and writes it. No other coordination.
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-start"
}]
}
]
Core of maybe-start:
def cmd_maybe_start():
if load_state(): return # already recording
if not tree_is_dirty(): return # nothing changed, skip
feature = branch_to_feature(current_branch())
if not feature:
feature = "session-" + datetime.now().strftime("%Y%m%d-%H%M")
save_state({"feature": feature, "auto_started": True, ...})
branch_to_feature("feature/pro") returns "pro". Developers already split work by branch, so reuse that mental boundary — the root reason this pipeline survives is that it asks the user to remember nothing new. On master, one-off scripts fall back to a timestamp.
Why the matcher is Edit/Write/MultiEdit: writing code is the real "I'm actually doing something" signal. Reading files, running tests, asking questions — not enough.
{
"matcher": "Bash",
"hooks": [{ "command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-stop" }]
}
def cmd_maybe_stop():
state = load_state()
if not state or not state.get("auto_started"): return
if current_branch() not in ("master", "main"): return
if tree_is_dirty(): return
clear_state()
Three gates: manual recordings don't stop, feature branches don't stop, dirty trees don't stop.
Why hooked on Bash instead of Edit: after merging back to master you usually aren't editing anymore, but you'll definitely run git status / git log / bin/rails test — any command at all gives the hook a chance to notice "time to wrap up."
The auto_started flag is load-bearing. If you kick off /record-feature pro-launch and cross multiple branches across multiple merges, auto-stop rules would kill the recording mid-feature. Manual recordings never auto-stop — only /stop-recording.
Not a Claude Code hook — this is .git/hooks/post-commit:
#!/bin/bash
ROOT=$(git rev-parse --show-toplevel) || exit 0
CLAUDE_PROJECT_DIR="$ROOT" "$ROOT/bin/recording-state" commit || true
recording-state commit appends the full commit message to raw.md:
### Commit 2026-04-16 21:55: `71f38a1`
> Add pricing page and expand account UI (P6 phases 1-2)
>
> Pricing (/pricing):
> - Displays all 6 plans in monthly/yearly grid with Stimulus toggle
> - Anonymous users see Subscribe → sign-in flow
> ...
Why commit messages matter: they're the hand-written summary you (or Claude) produced at the moment when the feature was just done and context was complete. More accurate than later recall, shorter than the transcript, more abstract than the diff.
When Claude writes a commit message, it's essentially writing summary material for your future article — you just need it done well the first time, not rewritten later.
"Stop": [{
"hooks": [{ "command": "$CLAUDE_PROJECT_DIR/bin/extract-session-notes" }]
}]
When a session ends, Claude Code passes the transcript_path to the hook via stdin JSON. extract-session-notes opens that jsonl, walks it line by line, and bucket-sorts:
keep_patterns = (
"test", "spec", "rspec", "minitest",
"kamal", "git commit", "git push",
"rails db", "rails routes", "rails runner",
"migrate", "curl -X", "curl -s -X",
)
if any(kw in cmd for kw in keep_patterns):
bash_cmds.append({"cmd": cmd[:400], "desc": desc})
Only whitelisted bash commands get captured. 90% of a dev session's bash is ls / cat / grep / head — articles don't use any of it, all filtered out. What's left (running tests, kamal, rails runner, curl against APIs) is the stuff with a story attached.
User prompts wrapped in <command-*> or <system-*> tags are dropped — only the real input survives. Edit/Write paths get deduped into a set. Error output capped at 400 chars. Task sub-agent calls are kept (prompt truncated to 2000 chars, enough to see what the sub-agent actually did).
The Stop hook doesn't fire once per recording — it fires every time a session ends. On the same feature branch you might open and close Claude Code five or six times. If each Stop re-parsed the whole transcript and wrote everything, raw.md would drown in duplicates.
Fix: put a cursor in the state file called last_extracted_at:
filter_after = state.get("last_extracted_at") or state.get("started_at")
events = extract_events(transcript_path, filter_after)
# ...after writing...
state["last_extracted_at"] = datetime.now().astimezone().isoformat()
save_state(project_dir, state)
Each pass only picks up events after the cursor. Simple, but forget it and you get hundreds of duplicated lines.
/record-feature NAME:
{
"feature": "NAME",
"started_at": "ISO timestamp",
"branch": "current branch",
"auto_started": false
}
That auto_started: false line disables auto-stop. Use cases: writing a one-off script on master but still wanting a trail, a feature spanning multiple branches, or just declaring "this one's for an article."
/stop-recording: runs one final extraction against the most recent jsonl, then clears the state file.
Real snippet (from docs/notes/pro/raw.md):
## Session 2026-04-16 21:52 (`7a81bf9d`)
### User prompts
- 根据环境变量直接写好对应只,不用传。
### Files edited/written
- `config/deploy.yml`
- `config/initializers/x402.rb`
### Commit 2026-04-17 00:13: `f87ea8e`
> Add production credentials (Stripe live + x402 mainnet)
> ...
### Commit 2026-04-17 00:57: `eba9ac9`
> Fix production x402 wallet_address (stray fullwidth '?' at end)
Writing an article: just grep. "Which commit was the wallet-address bug fix?" → eba9ac9. "How did I prompt the refactor sub-agent?" → look under ### Sub-agent invocations.
docs/notes/ is in .gitignore — it's draft fodder for your writing, not source code.
1. Branch as the feature boundary, not a separate metadata system. Devs already partition work by branch; adding another "feature name" layer would drift. Reuse the existing boundary, zero cognitive load.
2. Whitelist bash, don't blacklist. 95% of transcript bash is noise. Maintaining a "what's worth keeping" list stays stable over years; maintaining a "what to filter out" list doesn't.
3. Cursor lives in the state file, not the transcript. The transcript belongs to Claude Code; state belongs to the pipeline. Decoupled — Claude Code can rev the transcript format without breaking me, and I can rewrite extraction logic without touching transcripts.
Writing a 1500-word article takes material from roughly 30 commits, 4 sessions, a dozen key bash commands. Gathering it by hand costs about an hour — enough to make you cut corners next time.
Let hooks do it: zero marginal cost. Install this once and just work normally — check out a branch, write code, ship — and the article material piles up on its own.
Every commit reference, bash snippet, error line, and file path in this article came from docs/notes/pro/raw.md — draft material the hook recorded itself starting the moment I checked out feature/pro two days ago. When it was time to write, I opened the file and everything I needed was there.
The best way to let Claude write articles about Claude Code is to first let Claude write hooks to record itself.