1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の Stop フックで会話ログを Obsidian に自動保存する

1
Posted at

TL;DR

  • Claude Code の会話は ~/.claude/projects/ 配下に JSONL で残るが、人間にはまず読めないし検索もしづらい
  • Stop フック(応答が終わるたびに発火)で JSONL を Markdown に変換し、Obsidian Vault に同一ファイルへ逐次上書きする運用にした
  • thinking の内容やツール実行結果の詳細は保存せず、会話本文とツール呼び出しの1行要約だけを残す。可読性のために完全性を捨てた設計
  • v1 では同じセッションのノートが複数フォルダに重複する不具合があり、原因を「保存先が揺れる仕組みそのもの」まで遡って直した(v2)。この顛末が今回のメイン

課題:Claude Code の会話は JSONL に残るが、読めないし検索できない

Claude Code は各セッションの全会話を ~/.claude/projects/<プロジェクトごとのディレクトリ>/<session_id>.jsonl に自動保存してくれます。ただし人間が読む前提のフォーマットではなく、1メッセージ1JSONの行が並ぶだけ。thinking、tool_use、tool_result、system-reminder が全部混在していて、「あのときどう判断してこの実装にしたんだっけ」を後から追うのはかなりしんどいです。

普段のメモや技術的な判断は Obsidian に集約しているので、Claude Code のセッションもそこに落とし込んで検索・リンクできるようにしたい、というのが出発点でした。

選択肢と判断:なぜ「Stop フックで逐次上書き」にしたか

会話ログを Obsidian に持っていく方法はいくつか考えられます。

選択肢 内容 判断
毎回手でエクスポート 気になったセッションだけ手動でコピペ・変換 忘れる前提の運用は結局続かない。却下
セッション終了時に一括変換 SessionEnd フック等で終了時に1回だけ変換 長時間セッションだと終わるまで Obsidian 側に何も残らず、異常終了時はそのまま失う。却下
Stop フックで逐次上書き Claude が応答するたびに毎回 Markdown を作り直して同じファイルに上書き セッションの途中経過もリアルタイムで Obsidian 側に反映される。採用

Stop フックは「Claude が1回の応答を終えるたび」に呼ばれるので、頻度としては高めです。ここで捨てたのはログとしての完全性です。具体的には次を保存対象から外しました。

  • thinking の内容(思考過程は Obsidian 側で振り返る情報としては冗長すぎる)
  • tool_result の詳細(コマンド出力やファイル内容の全文は、必要ならリポジトリ側に残っている)
  • system-reminder やハーネスが注入する定型テキスト(ノイズにしかならない)

残したのは「会話本文」と「ツール呼び出しの1行要約」だけ。完全なログが欲しければ元の JSONL を見ればいい、Obsidian のノートは後から読み返して意思決定を追える最低限の粒度にする、という割り切りです。

Stop フックは「1回の応答終了」ごとに呼ばれるため、長いセッションでは何十回も実行されます。そのため後述するように「必ず高速に終わり、失敗しても Claude Code を止めない」ことが設計上の最優先事項になります。

仕組みの全体像

流れはシンプルです。

~/.claude/settings.json にはこう登録します(command のパスは各自の配置場所に合わせてください)。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/hooks/save_session_to_obsidian.py"
          }
        ]
      }
    ]
  }
}

Stop イベントの stdin には session_id / transcript_path / cwd などを含む JSON が渡されます。スクリプトはそこから transcript_path(元の JSONL のパス)を受け取り、都度パースし直して Markdown を再生成し、Vault 内の同じファイルに上書きします。差分更新ではなく毎回フルリビルドなので、ロジックが単純なまま保てます。

コード解説

全文は以下のとおりです(そのまま ~/.claude/hooks/ などに置いて使えます)。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Claude Code の Stop フック用スクリプト。

Claude が応答を終えるたびに(Stop イベント)、現在のセッションのトランスクリプト
(JSONL)を読み取り、人間が読みやすい Markdown ノートとして Obsidian Vault に
逐次保存(上書き更新)する。

入力: stdin に Stop フックのイベント JSON が渡される
  例: {"session_id": "...", "transcript_path": "...", "cwd": "...",
       "hook_event_name": "Stop", "stop_hook_active": false}

設計方針:
  - 失敗してもフローを止めないよう、例外は握りつぶして必ず exit 0 で終了する。
  - 同一セッションは同じノートファイルを上書き更新する(タイトル変更時は旧ファイルを除去)。
  - 内容は「会話本文+ツール実行の1行要約」。thinking とツール結果の詳細は省く。

v2 (2026-07-03) の変更点 — セッション重複エクスポートの根治:
  - プロジェクト名は cwd から .git を上方探索したリポジトリルート名で決定する。
    サブディレクトリで起動・cd しても同じフォルダに集約される(リポ外は cwd 名に
    フォールバック)。
  - cwd はトランスクリプト内の「最初」の値を採用(従来は最後の値だったため、
    セッション中の cd / ワークスペース切替のたびに保存先フォルダが変わり、
    旧フォルダにコピーが残っていた)。
  - 旧ノートの除去を Claude Code Sessions/ 配下のフォルダ横断に拡張。
    同一セッションIDのノートは常に1ファイルだけになる。空になったフォルダは畳む。
"""

import sys
import os
import json
import glob
import re
from datetime import datetime, timedelta, timezone

# ── 設定(必要に応じてここを変更)────────────────────────────────
# Obsidian Vault のルートパス(環境変数 OBSIDIAN_VAULT_DIR で上書き可・テスト用)
VAULT_DIR = os.environ.get("OBSIDIAN_VAULT_DIR", os.path.expanduser("~/Documents/Obsidian Vault"))
# Vault 内でセッションノートを格納するサブフォルダ
NOTES_SUBDIR = "Claude Code Sessions"
# 表示用タイムゾーン(日本標準時 = UTC+9)
JST = timezone(timedelta(hours=9))
# ─────────────────────────────────────────────────────────────


def to_jst(iso_str):
    """ISO8601 文字列(UTC, 末尾 Z)を JST の "YYYY-MM-DD HH:MM" 文字列に変換する。"""
    if not iso_str:
        return ""
    try:
        dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
        return dt.astimezone(JST).strftime("%Y-%m-%d %H:%M")
    except Exception:
        return iso_str


def sanitize_filename(name):
    """ファイル名に使えない文字を除去・置換する。"""
    name = re.sub(r'[\\/:*?"<>|#\^\[\]]', " ", name)
    name = re.sub(r"\s+", " ", name).strip()
    return name[:80] if name else "untitled"


def find_project_root(path):
    """path から上方向に .git(ディレクトリ or worktree のファイル)を探し、
    リポジトリルートの絶対パスを返す。見つからなければ None。"""
    try:
        cur = os.path.abspath(path)
        while True:
            if os.path.exists(os.path.join(cur, ".git")):
                return cur
            parent = os.path.dirname(cur)
            if parent == cur:  # ファイルシステムルートに到達
                return None
            cur = parent
    except Exception:
        return None


def is_noise_text(text):
    """システム注入されたテキスト(ユーザーの実発言ではないもの)を除外判定する。"""
    t = text.strip()
    if not t:
        return True
    # ハーネスが注入する各種ラッパー / リマインダーを除外
    noisy_prefixes = (
        "<command-name>",
        "<command-message>",
        "<command-args>",
        "<local-command-stdout>",
        "<local-command-stderr>",
        "Caveat: The messages below",
        "<system-reminder>",
    )
    if t.startswith(noisy_prefixes):
        return True
    return False


def strip_system_reminders(text):
    """本文中の <system-reminder>...</system-reminder> ブロックを取り除く。"""
    return re.sub(r"<system-reminder>.*?</system-reminder>", "", text, flags=re.DOTALL).strip()


def summarize_tool(name, tool_input):
    """ツール呼び出しを 1 行に要約する。"""
    ti = tool_input if isinstance(tool_input, dict) else {}

    def base(p):
        try:
            return os.path.basename(str(p))
        except Exception:
            return str(p)

    if name == "Bash":
        desc = ti.get("description") or ti.get("command") or ""
        desc = str(desc).replace("\n", " ").strip()
        return f"`Bash` {desc[:80]}" if desc else "`Bash`"
    if name in ("Edit", "Write", "Read", "NotebookEdit"):
        return f"`{name}` {base(ti.get('file_path', ''))}"
    if name in ("Grep", "Glob"):
        return f"`{name}` {str(ti.get('pattern', ''))[:60]}"
    if name in ("Task", "Agent"):
        return f"`{name}` {str(ti.get('description', ''))[:60]}"
    if name == "Skill":
        return f"`Skill` {ti.get('skill', '')}"
    if name == "TodoWrite":
        return "`TodoWrite` ToDo更新"
    if name in ("WebFetch", "WebSearch"):
        return f"`{name}` {str(ti.get('url') or ti.get('query', ''))[:60]}"
    if name.startswith("mcp__"):
        return f"`{name}`"
    return f"`{name}`"


def parse_transcript(path):
    """トランスクリプト JSONL を読み、メタ情報とイベント列を抽出する。"""
    title = ""
    cwd = ""
    git_branch = ""
    session_id = ""
    first_ts = ""
    last_ts = ""
    events = []  # {role, kind('text'|'tool'), ...}

    with open(path, "r", encoding="utf-8") as fp:
        for line in fp:
            line = line.strip()
            if not line:
                continue
            try:
                rec = json.loads(line)
            except Exception:
                continue

            rtype = rec.get("type")
            ts = rec.get("timestamp")
            if ts:
                if not first_ts:
                    first_ts = ts
                last_ts = ts
            if rec.get("sessionId"):
                session_id = rec["sessionId"]
            # cwd は「最初」の値を採用する(保存先フォルダの安定化。v2変更点)
            if rec.get("cwd") and not cwd:
                cwd = rec["cwd"]
            if rec.get("gitBranch"):
                git_branch = rec["gitBranch"]

            # AI が付与したセッションタイトル(最新を採用)
            if rtype == "ai-title" and rec.get("aiTitle"):
                title = rec["aiTitle"]
                continue

            msg = rec.get("message")
            if not isinstance(msg, dict):
                continue
            content = msg.get("content")

            if rtype == "user":
                # 文字列 or リスト形式の両方に対応
                items = content if isinstance(content, list) else [{"type": "text", "text": content}]
                for it in items:
                    if not isinstance(it, dict):
                        continue
                    # tool_result(ツール実行結果のエコー)は本文ではないので除外
                    if it.get("type") == "tool_result":
                        continue
                    if it.get("type") == "text":
                        txt = it.get("text") or ""
                        if is_noise_text(txt):
                            continue
                        txt = strip_system_reminders(txt)
                        if txt:
                            events.append({"role": "user", "kind": "text", "text": txt})

            elif rtype == "assistant":
                if not isinstance(content, list):
                    continue
                for it in content:
                    if not isinstance(it, dict):
                        continue
                    itype = it.get("type")
                    if itype == "text":
                        txt = (it.get("text") or "").strip()
                        if txt:
                            events.append({"role": "assistant", "kind": "text", "text": txt})
                    elif itype == "tool_use":
                        events.append({
                            "role": "assistant",
                            "kind": "tool",
                            "summary": summarize_tool(it.get("name", ""), it.get("input")),
                        })
                    # thinking は仕様により除外

    return {
        "title": title,
        "cwd": cwd,
        "git_branch": git_branch,
        "session_id": session_id,
        "first_ts": first_ts,
        "last_ts": last_ts,
        "events": events,
    }


def render_markdown(meta, project):
    """抽出結果から Markdown ノート本文を生成する。"""
    title = meta["title"] or "(無題セッション)"
    short_id = (meta["session_id"] or "")[:8]
    created = to_jst(meta["first_ts"])
    updated = to_jst(meta["last_ts"])

    # YAML フロントマター用にダブルクォートをエスケープ
    yaml_title = title.replace('"', "'")

    lines = []
    lines.append("---")
    lines.append(f'title: "{yaml_title}"')
    lines.append(f"session_id: {meta['session_id']}")
    lines.append(f"project: {project}")
    lines.append(f"cwd: {meta['cwd']}")
    lines.append(f"git_branch: {meta['git_branch']}")
    lines.append(f'created: "{created}"')
    lines.append(f'updated: "{updated}"')
    lines.append("tags: [claude-code, session]")
    lines.append("---")
    lines.append("")
    lines.append(f"# {title}")
    lines.append("")
    lines.append("> [!info] セッション情報")
    lines.append(f"> - **プロジェクト**: {project}")
    if meta["git_branch"]:
        lines.append(f"> - **ブランチ**: `{meta['git_branch']}`")
    lines.append(f"> - **開始**: {created}")
    lines.append(f"> - **更新**: {updated}")
    lines.append(f"> - **セッションID**: `{short_id}`")
    lines.append("")
    lines.append("---")
    lines.append("")

    # 会話を順に描画。連続するツール呼び出しはまとめて引用ブロックにする。
    last_role = None
    pending_tools = []

    def flush_tools():
        if pending_tools:
            for s in pending_tools:
                lines.append(f"> 🔧 {s}")
            lines.append("")
            pending_tools.clear()

    for ev in meta["events"]:
        if ev["kind"] == "tool":
            pending_tools.append(ev["summary"])
            continue

        # text イベント
        flush_tools()
        if ev["role"] == "user":
            if last_role != "user":
                lines.append("## 🧑 User")
                lines.append("")
            lines.append(ev["text"])
            lines.append("")
            last_role = "user"
        else:  # assistant
            if last_role != "assistant":
                lines.append("## 🤖 Assistant")
                lines.append("")
            lines.append(ev["text"])
            lines.append("")
            last_role = "assistant"

    flush_tools()

    lines.append("---")
    lines.append("")
    lines.append(f"*このノートは Claude Code の Stop フックにより自動保存されています(更新: {updated})。*")
    lines.append("")
    return "\n".join(lines)


def main():
    raw = sys.stdin.read()
    try:
        payload = json.loads(raw) if raw.strip() else {}
    except Exception:
        return

    transcript_path = payload.get("transcript_path")
    if not transcript_path or not os.path.isfile(transcript_path):
        return

    meta = parse_transcript(transcript_path)

    # 会話が空(まだ実質的なやり取りがない)なら書き込まない
    if not meta["events"]:
        return

    # プロジェクト名の決定(v2):
    #   1. cwd(トランスクリプト最初の値。無ければフックの cwd)から .git を上方探索し、
    #      リポジトリルートのディレクトリ名を採用
    #   2. リポジトリ外ならこれまで通り cwd の末尾ディレクトリ名にフォールバック
    cwd = meta["cwd"] or payload.get("cwd") or ""
    repo_root = find_project_root(cwd) if cwd else None
    base_dir = repo_root or cwd
    project = os.path.basename(base_dir.rstrip("/")) or "unknown-project"

    # 保存先フォルダ: <Vault>/Claude Code Sessions/<project>/
    notes_root = os.path.join(VAULT_DIR, NOTES_SUBDIR)
    out_dir = os.path.join(notes_root, project)
    os.makedirs(out_dir, exist_ok=True)

    short_id = (meta["session_id"] or os.path.basename(transcript_path).split(".")[0])[:8]
    date_prefix = to_jst(meta["first_ts"])[:10] or "0000-00-00"
    title_slug = sanitize_filename(meta["title"] or "session")
    filename = f"{date_prefix} {title_slug} ({short_id}).md"
    out_path = os.path.join(out_dir, filename)

    # 同一セッションの旧ノートを「フォルダ横断」で除去する(v2変更点)。
    # タイトル変更による旧ファイル名も、cd 等で別フォルダに残ったコピーもここで消える。
    pattern = os.path.join(glob.escape(notes_root), "*", f"* ({short_id}).md")
    for old in glob.glob(pattern):
        if os.path.abspath(old) == os.path.abspath(out_path):
            continue
        try:
            os.remove(old)
            # 空になった旧フォルダは畳む(空でなければ OSError となり無視される)
            os.rmdir(os.path.dirname(old))
        except Exception:
            pass

    content = render_markdown(meta, project)
    with open(out_path, "w", encoding="utf-8") as fp:
        fp.write(content)


if __name__ == "__main__":
    try:
        main()
    except Exception:
        # フックの失敗で Claude Code の動作を止めないよう、常に正常終了する
        pass
    sys.exit(0)

要点を関数ごとに見ていきます。

main() — とにかく exit 0 で終わる

main() を丸ごと try/except し、何が起きても sys.exit(0) で終わります。フックが失敗して Claude Code 本体を止めては本末転倒なので、「ノート保存に失敗する」より「Claude Code を止めない」ことを優先しています。stdin の JSON パース失敗や transcript_path 不在時も、例外を投げず黙って return するだけです。

ノイズ除去 — is_noise_text / strip_system_reminders / tool_result の除外

トランスクリプトの user メッセージには、実際の発言だけでなくスラッシュコマンドの展開結果(<command-name> 等)や <system-reminder> ブロック、ツール実行結果のエコー(tool_result)も混ざっています。そのままだと本文がノイズだらけになるので、

  • ハーネス由来のプレフィックスで始まるテキストは丸ごと除外(is_noise_text)
  • 本文中に埋め込まれた <system-reminder>...</system-reminder> は正規表現で剥がす(strip_system_reminders)
  • tool_result タイプの要素はそもそも本文として扱わない

の3段構えで落としています。

ツール呼び出しの1行要約 — summarize_tool

Bash ならコマンドの説明文、Edit/Write/Read ならファイル名、Grep/Glob ならパターン、とツール名ごとに「何をしたか」を1行に圧縮します。実行結果の中身(出力全文や差分)は保存しません。これは前述の「可読性のために完全性を捨てる」判断そのものです。render_markdown 側では連続するツール呼び出しをまとめて引用ブロック(> 🔧 ...)にし、会話の本筋を邪魔しないようにしています。

タイトル取得と旧ファイル除去

Claude Code はセッションに ai-title というレコードでタイトルを付与します。parse_transcript はこれを見つけるたびに上書きするので、途中でタイトルが変わっても最新が採用されます。

ファイル名にはこのタイトルをスラッグ化して含めているため(sanitize_filename)、タイトルが変わればファイル名も変わり、旧タイトルのファイルが残骸として残ります。そこで main() の後半で glob を使い、同じセッションID(ファイル名末尾の (short_id))を持つ他のファイルを探して削除しています。

ハマった話:v1では同じセッションのノートが複数フォルダに重複していた

ここが今回いちばん書きたかったところです。v1 を使っていると、同じセッションなのに Claude Code Sessions/ 配下の複数のプロジェクトフォルダにノートが分裂している現象に気づきました。

最初は「重複ファイルを消すクリーンアップスクリプトを別途足せばいいか」と考えかけたのですが、それだと発生源を放置したまま対症療法を重ねるだけで、次のセッションでもまた同じことが起きます。なので、なぜ保存先フォルダがセッション中に変わるのか、原因まで遡りました。

原因は2つ重なっていました。

  1. cwd の「最後」の値を採用していた: v1 では JSONL を走査しながら cwd を毎回上書きしていたため、セッション中に cd したりワークスペースを切り替えたりすると、最終的に残る cwd はセッションの最初の作業ディレクトリとは限りませんでした。同じセッションでも、フックが発火するたびに直近の cwd が違えば保存先も違ってしまいます。
  2. プロジェクト名が cwd の末尾ディレクトリ名そのものだった: リポジトリのサブディレクトリで作業していると、cwd の末尾ディレクトリ名はリポジトリ名と一致しません。同じリポジトリの中でも、どのサブディレクトリで作業していたかによって別プロジェクト扱いになっていました。

この2つが組み合わさることで、「1つのセッションなのに、途中の cd のたびに保存先フォルダが変わり、旧フォルダにコピーが残る」という状態になっていたわけです。

対策は v2 のコメントに書いたとおり3点です。

  • cwd は JSONL 内で最初に出てきた値を採用する(if rec.get("cwd") and not cwd: の1行)
  • プロジェクト名は cwd の末尾ディレクトリ名ではなく、.git を上方探索して見つけたリポジトリルートのディレクトリ名にする(find_project_root)。サブディレクトリで作業していても同じフォルダに集約される
  • 旧ノートの除去を単一フォルダ内ではなく、Claude Code Sessions/ 配下のフォルダ横断に広げる(glob.escape(notes_root) + "/*/* (short_id).md")。空になったフォルダは os.rmdir で畳む

「重複を消すスクリプトを足す」は対症療法です。保存先が決まるロジック(cwd の採用ルール・プロジェクト名の決め方)そのものにブレがある限り、掃除しても次のセッションでまた増えます。今回は「なぜ保存先が毎回同じにならないのか」まで遡ったことで、掃除自体が要らない設計になりました。

運用に組み込んだ形と今後

いまはこのスクリプトを Stop フックに登録しっぱなしにしていて、Claude Code を使うたびに Obsidian 側の Claude Code Sessions/<プロジェクト名>/ にノートが自然に増えていきます。ノートの frontmatter には tags: [claude-code, session] と project プロパティを付けているので、Obsidian 側では

  • タグ #claude-code や #session での検索
  • project プロパティを条件にした Dataview クエリ(プロジェクトごとの一覧化など)

がそのまま使えます。会話本文はそのままファイル内全文検索の対象になるので、「あのときどんな判断でこの実装にしたか」を Obsidian の検索から辿れるようになったのは体感として大きいです。

今後の改善候補としては、ツール呼び出しの要約をもう少しリッチにする(例えば Edit の差分行数を添える)、複数セッションをまたいだプロジェクト単位のサマリーノートを別途生成する、あたりを考えています。ただしこれもまず「本当に読み返す場面で困っているか」を見てから手を付けるつもりで、今のところは逐次上書きの仕組みだけで十分回っています。

1
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?