Moving how2claude's recording system to another Rails project: 4 files, 5 steps, hook layering.
how2claude has a set of hooks that auto-record Claude Code sessions — starts recording when work begins, appends a checkpoint on every commit, and on session end extracts prompts / bash / edit lists into docs/notes/<feature>/raw.md. The let-claude-record-itself article covered how it was built.
Problem: my other project (smarts, a smart-contract docs site) didn't have any of it. Every time I wanted to write an article afterwards, I'd dig through git log and memory and feel like I was missing the good stuff. This article covers porting the recording system over — 4 files, 5 minutes total — but along the way a real insight about hook layering surfaced: Claude Code hooks and git hooks operate at completely different layers, and what gets captured when you mix tools (Amp + Claude Code, say) depends on which layer you installed into.
Here's everything in how2claude related to recording:
| File | Layer | Role |
|---|---|---|
bin/recording-state |
script | Python helper, manages the .state.json lifecycle |
bin/extract-session-notes |
script | Python helper, reads Claude Code transcript → writes raw.md |
.claude/settings.local.json |
Claude Code hook | PostToolUse / Stop trigger the scripts above |
.git/hooks/post-commit |
git hook | every commit calls recording-state commit for a checkpoint |
.gitignore |
noise control | keeps docs/notes/ out of the repo (notes are private/transient) |
4 pieces, 4 different layers. That distinction comes back repeatedly below.
Two hook systems are in play, with wildly different scopes:
Claude Code hooks (defined in .claude/settings.local.json):
- Scope: only fires inside the Claude Code tool
- Triggers: PostToolUse / Stop / PreToolUse — Claude Code lifecycle events
- Info available: tool name, args, transcript_path (full session jsonl) — things only Claude Code knows
Git hooks (shell scripts under .git/hooks/):
- Scope: fires on every git event, regardless of who triggered git
- Triggers: post-commit / pre-push / etc.
- Info available: what git itself knows (sha, author, branch, diff)
Real consequence: write code + commit inside Claude Code and both hook layers fire — session info and commit info both land in raw.md. Switch to Amp (or Cursor, or hand-typing) to write + commit and only the git hook fires — raw.md gets the commit skeleton but no session prompts / bash / edit detail.
This isn't a bug — it's each tool's design constraint. Wanting session-level detail under all tools means installing your own hook layer per tool. The git fallback gives you "what was done"; it doesn't give you "what was thought, what went wrong."
Selection guide:
- Tool-agnostic skeleton (commit info, code changes) → put in a git hook
- Claude-Code-specific flesh (full prompts, reasoning) → put in a Claude Code hook
- Both → install in both layers
Installing the same recording setup into smarts (/home/bob/Work/smarts, a Rails project).
mkdir -p /home/bob/Work/smarts/bin
cp /home/bob/Work/how2claude/bin/recording-state \
/home/bob/Work/smarts/bin/recording-state
cp /home/bob/Work/how2claude/bin/extract-session-notes \
/home/bob/Work/smarts/bin/extract-session-notes
chmod +x /home/bob/Work/smarts/bin/{recording-state,extract-session-notes}
Scripts unmodified — they use the $CLAUDE_PROJECT_DIR env var to decide where to write:
def project_dir():
return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
def state_path():
return pathlib.Path(project_dir()) / "docs/notes/.state.json"
This is the key abstraction: Claude Code sets CLAUDE_PROJECT_DIR automatically when firing hooks; we manually set it inside the git post-commit. Both sides honor the same env var, and the script doesn't need to figure out "which project am I in."
.claude/settings.local.jsonOne decision here: port only the hooks, not the permissions list.
how2claude's settings.local.json has 100+ permissions.allow entries — all specific to how2claude (curl localhost:3000, bin/rails runner, kamal app exec). Zero meaning carrying those over to smarts. smarts will accumulate its own permissions organically as you use it.
Hooks are patterns — cross-project identical. Permissions are project state — cross-project different.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-start"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-stop"
}
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/bin/extract-session-notes" }
]
}
]
}
}
Three trigger points:
- File edited → try to start recording (maybe-start self-checks: skip if already recording, skip if tree clean)
- Bash command run → try to stop (maybe-stop is strict: only stops when "auto-started AND back on master AND tree clean" all hold)
- Session ends → extract the transcript into raw.md
.git/hooks/post-commit3 lines:
#!/bin/bash
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
CLAUDE_PROJECT_DIR="$ROOT" "$ROOT/bin/recording-state" commit || true
The manual CLAUDE_PROJECT_DIR=$ROOT export is the exact spot where git-world and Claude-Code-world get bridged by the same env var. The || true guarantees the hook never blocks a commit.
chmod +x /home/bob/Work/smarts/.git/hooks/post-commit
docs/notes/ to .gitignore# Session recording notes (transient, for article material)
docs/notes/
Notes are transient + private — you don't want raw.md committed into PRs; you don't want .state.json polluting git status. how2claude's gitignore handles it the same way.
Trigger maybe-start manually right after installing:
$ cd /home/bob/Work/smarts && CLAUDE_PROJECT_DIR=$(pwd) ./bin/recording-state maybe-start
[recording] auto-started: contract-to-docs (branch: feat/contract-to-docs)
$ cat docs/notes/.state.json
{
"feature": "contract-to-docs",
"started_at": "2026-04-20T17:47:18-04:00",
"branch": "feat/contract-to-docs",
"auto_started": true
}
The script correctly spotted that smarts happened to be on feat/contract-to-docs with a dirty tree — auto-started recording, derived the feature name contract-to-docs from the branch feat/contract-to-docs. That logic in the script:
def branch_to_feature(branch):
if not branch or branch in ("master", "main"):
return None
if "/" in branch:
return branch.split("/", 1)[1]
return branch
feat/X → X, feature/X → X, bare fix-y → fix-y, master/main → None (which falls back to a timestamped session-YYYYMMDD-HHMM name).
The heuristic is deliberately brainless — branch name is the subject of work, no need to ask you to name it again.
After installing I asked: I occasionally work in Amp — does that get captured? The answer is exactly what the hook-layering story predicted:
| Scenario | Claude Code hook | git hook | What lands in notes |
|---|---|---|---|
| Claude Code work + commit | ✅ fires | ✅ fires | session detail + commit skeleton |
| Amp work + commit | ❌ no-op | ✅ fires | commit skeleton only |
| Hand-typed + commit | ❌ no-op | ✅ fires | commit skeleton only |
| Claude Code work, no commit yet | ✅ starts recording | — | session detail (commit entry waits for next commit) |
Takeaway: plenty if Claude Code is the primary driver. Commit skeleton lands always; session detail lands only on the Claude Code path. For "what was done" articles you lean mostly on commit bodies — session prompts / bash trails are bonus, nice to have but not required.
If you use Amp heavily, Amp has its own hook system (I haven't dug into specifics); a small forwarding script firing recording-state maybe-start/maybe-stop would work the same way.
Porting Claude Code session recording to another project — the 5 moves:
cp two Python scripts to the target project's bin/. Zero modification — scripts honor $CLAUDE_PROJECT_DIR, cross-project-portable by design..claude/settings.local.json, hooks only. Don't port the permissions list — permissions are project state (different per project); hooks are patterns (identical across projects)..git/hooks/post-commit (3 lines), manually export CLAUDE_PROJECT_DIR=$ROOT and call recording-state commit. That's the one spot where git-world and Claude-Code-world get bridged by the same env var.docs/notes/ to .gitignore. Notes are transient + private, not part of the repo.CLAUDE_PROJECT_DIR=$(pwd) ./bin/recording-state maybe-start, verify the branch-to-feature inference is right. If tree is clean, the script is designed to skip — not a bug.The real design choice isn't "how do I port it" — porting is nearly cp. It's splitting the recording logic across 4 distinct layers, each doing one clear thing:
CLAUDE_PROJECT_DIREach of the 4 can be ported, replaced, or skipped independently (want Amp support? add an Amp-hook layer. Changing note format? edit Python. Don't want git tracking notes? drop the post-commit). Claude can write the code right — but the judgment call of "which layer does this functionality belong in" is not something it can make for you. That's a call about your tooling boundaries, and it's yours.