Free

Porting Claude Code Session Recording to a Second Project

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.


4 files, one picture

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.

The key insight: hook layering decides what gets captured

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

Porting in 5 moves

Installing the same recording setup into smarts (/home/bob/Work/smarts, a Rails project).

1. Copy the two Python scripts

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."

2. Create .claude/settings.local.json

One 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

3. Create .git/hooks/post-commit

3 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

4. Add 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.

5. Smoke test (with a small surprise)

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/XX, feature/XX, bare fix-yfix-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.

Visibility boundary when mixing with Amp

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.

Checklist

Porting Claude Code session recording to another project — the 5 moves:

  1. cp two Python scripts to the target project's bin/. Zero modification — scripts honor $CLAUDE_PROJECT_DIR, cross-project-portable by design.
  2. Create .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).
  3. Create .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.
  4. Add docs/notes/ to .gitignore. Notes are transient + private, not part of the repo.
  5. Manual smoke test: 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:

  • Python scripts: stateless helper, honors CLAUDE_PROJECT_DIR
  • Claude Code hook: in-tool events (session flesh)
  • git hook: tool-agnostic events (commit skeleton)
  • gitignore: noise control

Each 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.