【第二の脳】ObsidianとClaude Codeで、作業ログを自動で書いてもらう仕組みを作った

- Claude Codeの作業が終わるたびに、Obsidianの「作業ログ」へ自動で1件残る仕組みの中身
- Obsidianをインストールして、Claude Codeで自分のvaultを開くところからの手順
- そのまま貼るだけで同じ仕組みが作れるプロンプト全文
Obsidianに「作業ログ」というフォルダを作って、Claude Codeで何をしたか毎回書き残す運用にしていました。ところがこれがなかなか続きませんでした。作業が終わった瞬間はやることが他にもあって、後回しにしているうちに何をしたか忘れる。気づいたら1週間分が真っ白、ということが何度もありました。
書こうと決めたのに書けないなら、書く工程そのものを無くせばいい。そう考えて、Claude Codeのセッションが終わったタイミングで、裏側で勝手に1件書いてもらう仕組みを作りました。

仕組みはこうなっている
Claude Codeには「セッションが終わったときに決めたコマンドを実行する」というSessionEndフックがあります。ここに、セッションの記録(transcript)を要約してObsidianに書き込むPythonスクリプトを1本登録しているだけです。
フック自身は重い処理をしません。ジョブをファイルに書き出して、自分を切り離してすぐ制御を返します。裏で起動した子プロセスが、claude -pでHaikuモデルにやり取りを要約させ、「見出し・依頼・やったこと・触ったもの」の4行にまとめてMarkdownへ追記する。ここまでが全部自動です。

ファイルを何も変えていない、質問に一言答えただけのセッションまで律儀に記録すると一覧が埋まってしまうので、そこは書かなくていいとClaude自身に判断させています。迷ったときは書く、くらいの緩さにしています。
セットアップの手順

① Obsidianをインストールする
公式サイト(https://obsidian.md/)からダウンロードして、いつもどおりインストールします。すでに使っている人はこの手順は不要です。記録を残したいフォルダ(vault)を、普段使っているものでも新しく作ったものでも構いません。

② Claude Codeでそのフォルダを開く
ターミナルで、Obsidianのvaultにしているフォルダに移動してから `claude` を起動します。Claude Codeがそのフォルダを作業ディレクトリとして認識していれば、それで準備は完了です。セッション終了時のフックは、Claude Code自体の設定(`~/.claude/`)に登録するので、どのフォルダから起動しても同じ仕組みが動くようになります。
③ このプロンプトを貼って実行する
あとは以下をそのままコピーして、Claude Codeに貼るだけです。vaultのパスと、作業ログを置くフォルダ名の2つを聞かれるので答えると、Pythonスクリプトの作成、`~/.claude/settings.json` へのフック登録、動作確認まで一通りやってくれます。
python3が入っていない場合はインストールから、WindowsとMacの両方に対応するようにしてあります。実行する前に、pythonのインストールなど環境を変える操作は、必ず確認を求めてくるはずです。
- プロンプトはこちら
Claude Codeのセッションが終わるたびに、Obsidian vaultの「作業ログ」フォルダへ 自動で1件追記されるようにセットアップしてください。 ## 0. まず私に2つ質問してください - Obsidian vaultのパス(例: Macなら ~/obsidian、Windowsなら C:\Users\自分の名前\obsidian) - 作業ログを置くフォルダ名(例: 作業ログ) vault内にCLAUDE.mdがあり、そこに作業ログのルールが書いてあるならその値を提案してよいです。 ## 1. 前提: python3の確認・インストール まず自分(Claude Code)が今どのOS上で動いているか判定してください。 - **Mac/Linuxの場合**: `python3 --version` を確認します。無ければ、 `command -v brew` でHomebrewの有無を確認し、あれば `brew install python3` を 提案・実行してください(実行前に必ずユーザーに確認を取ります)。 brewが無ければ、https://www.python.org/downloads/macos/ からのインストールを 案内し、インストール後にもう一度声をかけてもらうよう伝えてください。 - **Windowsの場合**: `python --version` と `py --version` の両方を確認します。 どちらも無ければ、`winget install -e --id Python.Python.3.12` を 提案・実行してください(実行前に必ずユーザーに確認を取ります。 winget自体が無い場合はhttps://www.python.org/downloads/windows/ を案内し、 インストーラーで「Add python.exe to PATH」に必ずチェックを入れるよう伝えてください)。 python3が使えるようになったら、shutil.which相当の方法(Macなら`which python3`、 Windowsなら`where python`または`py -3 -c "import sys;print(sys.executable)"`)で **インタプリタの絶対パス**を控えてください。SessionEndフックはPATHが通っていない 環境で呼ばれることがあるため、settings.jsonには絶対パスを書き込みます。 同様に、`claude` コマンドの絶対パスも控えておいてください (`which claude` / `where claude`)。こちらはスクリプト内で自動探索もしますが、 念のため見つかることを確認してください。 ## 2. ~/.claude/hooks/obsidian_log.py を作成 OS問わず、ホームディレクトリ配下の `.claude/hooks/obsidian_log.py` に、 以下のスクリプトをそのままの内容で作成してください(改変不要、動作確認済みです)。 **1箇所だけ**、冒頭の `VAULT = ...` の行を、0で聞いたパスに置き換えてください。 例: `VAULT = pathlib.Path.home() / "obsidian" / "作業ログ"` Windowsのパスでも `pathlib.Path.home() / "obsidian" / "作業ログ"` のように 書けば自動的にOSごとの区切り文字で解決されるので、これで問題ありません。 ```python #!/usr/bin/env python3 """Claude Code のセッション終了時に ~/obsidian/作業ログ/ へ1件追記する。 2段構えで動きます。 親 … SessionEnd フックから同期で呼ばれます。足切りだけして、自分自身を --worker で切り離して起動し、すぐ終わります。フックのタイムアウトに 引っかからないためです。 子 … transcript を要約用のダイジェストに畳んで claude -p に渡し、 返ってきた3行を追記します。 子が起動する claude のセッションが終わると、またこのフックが走ります。 無限に増えるので、子には OBSIDIAN_LOG_SKIP=1 を持たせて先頭で止めます。 """ import datetime import json import os import pathlib import shutil import subprocess import sys import tempfile VAULT = pathlib.Path.home() / "obsidian" / "作業ログ" # ← 0で聞いた値に置き換える TRACE = pathlib.Path.home() / ".claude" / "hooks" / "obsidian_log.trace" SELF = pathlib.Path(__file__).resolve() EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} # これらしか使っていないセッションは「何も作らず何も変えていない」とみなします。 # Bash は読み書きどちらにも使えるので、ここには入れません。 READONLY_TOOLS = {"Read", "Grep", "Glob", "NotebookRead", "TodoWrite", "Skill"} MODEL = "claude-haiku-4-5-20251001" CLAUDE_TIMEOUT = 120 # 秒。超えたら機械的なフォーマットに落とします MAX_DIGEST_CHARS = 60000 # これを超えたら中略します MAX_ASSISTANT_CHARS = 600 # assistant の1発言をここまでに丸めます # 作業ディレクトリごとの「どこから」の出し分け。必要なら自分で足す。 # 例: WHERE_BY_CWD = {"my-scheduled-project": "定期タスク"} WHERE_BY_CWD = {} def trace(text): """呼ばれたこと自体を残す。フックが発火しているかの確認に使う。""" try: with open(TRACE, "a", encoding="utf-8") as fh: stamp = datetime.datetime.now().isoformat(timespec="seconds") fh.write("%s %s\n" % (stamp, text)) except Exception: pass def now_jst(): try: from zoneinfo import ZoneInfo return datetime.datetime.now(ZoneInfo("Asia/Tokyo")) except Exception: return datetime.datetime.now() # ---------------------------------------------------------------- transcript def _text_of(content): if isinstance(content, str): return content return "\n".join( b.get("text", "") for b in (content or []) if isinstance(b, dict) and b.get("type") == "text" ) def _tool_line(name, inp): """tool_use を1行に畳む。差分やコード本体は入れません。""" inp = inp or {} def g(*keys): for k in keys: v = inp.get(k) if isinstance(v, str) and v.strip(): return " ".join(v.split()) return "" arg = g("file_path", "notebook_path", "command", "url", "pattern", "path", "description", "query", "prompt", "skill") if not arg: return " [tool] %s" % name return " [tool] %s %s" % (name, arg[:200]) def read_transcript(path): """(ダイジェストの行, 編集されたファイル, 使われたツール名, ユーザー発言数) を返す。""" lines, files, tools, users = [], [], set(), 0 if not path or not os.path.exists(path): return lines, files, tools, users with open(path, encoding="utf-8", errors="replace") as fh: for line in fh: try: rec = json.loads(line) except Exception: continue msg = rec.get("message") or {} if rec.get("type") == "user" and msg.get("role") == "user": text = " ".join(_text_of(msg.get("content")).split()) # フック由来やシステム挿入の擬似メッセージは拾わない if not text or text.startswith("<") or text.startswith("Caveat:"): continue users += 1 lines.append("[ユーザー] " + text) elif rec.get("type") == "assistant": for block in (msg.get("content") or []): if not isinstance(block, dict): continue if block.get("type") == "text": text = " ".join((block.get("text") or "").split()) if text: lines.append("[Claude] " + text[:MAX_ASSISTANT_CHARS]) elif block.get("type") == "tool_use": name = block.get("name") or "?" inp = block.get("input") or {} tools.add(name) lines.append(_tool_line(name, inp)) if name in EDIT_TOOLS: p = inp.get("file_path") or inp.get("notebook_path") if p and p not in files: files.append(p) return lines, files, tools, users def build_digest(lines): """長すぎるときは真ん中を落として、頭と尻を残します。""" body = "\n".join(lines) if len(body) <= MAX_DIGEST_CHARS: return body head = MAX_DIGEST_CHARS // 5 tail = MAX_DIGEST_CHARS - head return body[:head] + "\n\n…(中略)…\n\n" + body[-tail:] # -------------------------------------------------------------------- prompt PROMPT = """あなたはObsidianの作業ログを書く担当です。 以下はClaude Codeの1セッションの記録です。読んで、作業ログを1件書いてください。 # 出力 次の4行だけを出力します。前置き・説明・コードブロックは付けません。 ## <セッション全体を一言で。25字程度> - 依頼: <セッション全体で頼まれたことを1行にまとめる。原文のままでなくてよい> - やったこと: <何をしたか1〜2行。手順は書かない> - 触ったもの: <変更・作成したファイル、push先、投稿URLなど。無ければ「なし」> # ルール - 見出しと箇条書きは体言止めで簡潔に書きます。「〜しました」の実況にはしません。 - 「触ったもの」には、下の記録に実際に現れたパス・URLだけを書きます。無いものは書きません。 ファイルの作成・変更は Edit や Write だけでなく、Bash のリダイレクトやヒアドキュメント、 git のコマンドからも読み取ってください。読んだだけのファイルは書きません。 パスは $HOME からの相対、またはファイル名で十分です。 数が多いときは主なものを5つほどに絞り、末尾に「ほかN件」と付けます。 - コードや差分、実行したコマンドの全文は書きません。 - ファイルを変更していない作業も、成果があるなら普通に書きます。 手順を順に提示して相手を進行させた、調べて結果を報告した、方針を決めた — どれも成果です。 {exception} # 例 ## Obsidianの作業ログを自動化するフックの作成 - 依頼: 作業のたびvaultに記録が残るようにしてほしい - やったこと: SessionEndフックからClaude Codeを呼び、セッションを要約して追記する形に変更 - 触ったもの: ~/.claude/hooks/obsidian_log.py、obsidian/CLAUDE.md # 補足 作業ディレクトリ: {cwd} {files} # 記録 {digest} """ EXCEPTION_ALLOWED = """# 例外 このセッションはファイルを1つも変更していません。ユーザーの短い質問に一言答えただけで、 調べた結果の報告も手順の提示も無いなら、SKIP の4文字だけを出力します。理由は書きません。 少しでも成果が残っているなら、SKIP せずに書きます。迷ったら書きます。 """ EXCEPTION_FORBIDDEN = """# 注意 このセッションは必ず1件書きます。SKIP は出力しません。 内容が薄くても、上の4行の形で書いてください。 """ SYSTEM = "指定された4行だけを出力します。前置き・分析・所感・説明は書きません。" def find_claude(): exe = shutil.which("claude") if exe: return exe if os.name == "nt": cands = ( pathlib.Path(os.environ.get("APPDATA", "")) / "npm" / "claude.cmd", pathlib.Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "claude" / "claude.exe", ) else: cands = ( pathlib.Path.home() / ".local" / "bin" / "claude", pathlib.Path("/opt/homebrew/bin/claude"), pathlib.Path("/usr/local/bin/claude"), ) for cand in cands: if cand.exists(): return str(cand) return "" def ask_claude(prompt): """claude -p に投げて本文を受け取る。失敗したら空文字を返します。""" exe = find_claude() if not exe: trace("WARN claude が見つかりません") return "" env = dict(os.environ) env["OBSIDIAN_LOG_SKIP"] = "1" # 呼び先の SessionEnd でこのフックを止める try: proc = subprocess.run( [exe, "-p", "--model", MODEL, "--append-system-prompt", SYSTEM], input=prompt, capture_output=True, text=True, timeout=CLAUDE_TIMEOUT, env=env, cwd=tempfile.gettempdir(), ) except subprocess.TimeoutExpired: trace("WARN claude がタイムアウトしました") return "" except Exception as exc: trace("WARN claude を起動できませんでした: %r" % (exc,)) return "" if proc.returncode != 0: trace("WARN claude が異常終了しました rc=%s %s" % (proc.returncode, " ".join((proc.stderr or "").split())[:200])) return "" return proc.stdout or "" def parse_reply(text): """(見出し, 依頼, やったこと, 触ったもの) か、SKIP なら None を返します。""" body = (text or "").strip() if not body: return None if body.replace("`", "").strip().upper().startswith("SKIP"): return "SKIP", "", "", "" head, req, did, touched = "", "", "", "" for raw in body.splitlines(): line = raw.strip().lstrip("`").strip() if not head and line.startswith("## "): head = line[3:].strip() elif line.startswith("- 依頼:"): req = line.split(":", 1)[1].strip() elif line.startswith("- やったこと:"): did = line.split(":", 1)[1].strip() elif line.startswith("- 触ったもの:"): touched = line.split(":", 1)[1].strip() if not (head and req and did): return None # 見出しに時刻や「ClaudeCode —」を足してきた場合は剥がす if " — " in head: head = head.split(" — ", 1)[1].strip() return head, req, did, touched or "なし" # --------------------------------------------------------------------- write def append_entry(note, marker, when, where, head, req, did, touched): entry = ( "\n## %s %s — %s\n" "- 依頼: %s\n" "- やったこと: %s\n" "- 触ったもの: %s %s\n" ) % (when.strftime("%H:%M"), where, head, req, did, touched, marker) with open(note, "a", encoding="utf-8") as fh: fh.write(entry) def ensure_note(day, vault): vault.mkdir(parents=True, exist_ok=True) note = vault / (day + ".md") if not note.exists(): note.write_text( "---\ndate: %s\ntype: 作業ログ\n---\n\n# %s\n" % (day, day), encoding="utf-8", ) return note def fallback(files, cwd): """claude が使えなかったときの、昔ながらの機械的な1件。""" touched = "、".join(os.path.basename(p) for p in files[:5]) if files else "なし" if len(files) > 5: touched += " ほか%d件" % (len(files) - 5) where = os.path.basename(cwd) or cwd or "不明" return ( "%s での作業(自動要約に失敗)" % where, "(要約できませんでした。transcript を参照してください)", "%s で作業し、ファイルを %d 件変更" % (where, len(files)), touched, ) # --------------------------------------------------------------------- worker def worker(job_path): try: job = json.loads(pathlib.Path(job_path).read_text(encoding="utf-8")) except Exception as exc: trace("ERR ジョブを読めませんでした: %r" % (exc,)) return 0 finally: try: os.unlink(job_path) except Exception: pass cwd = job.get("cwd") or "" sid = job.get("session_id") or "" marker = "<!-- session:%s -->" % sid vault = pathlib.Path(job.get("vault") or VAULT) when = datetime.datetime.fromisoformat(job["when"]) day = when.strftime("%Y-%m-%d") note = vault / (day + ".md") # ファイルは書くと決まってから作ります。SKIP の日に空ファイルを残さないためです。 if note.exists() and marker in note.read_text(encoding="utf-8"): trace("SKIP 記録済み " + sid) return 0 lines, files, tools, users = read_transcript(job.get("transcript_path")) # SKIP を許すのは、本当に何も作らず何も変えていないときだけ。 # 判断をClaudeに丸投げすると同じセッションでも結果がぶれるので、ここで決めます。 skip_allowed = not files and users <= 2 and tools <= READONLY_TOOLS hint = ("編集ツールで変更されたファイル: %s\n" % "、".join(files)) if files else "" prompt = PROMPT.format( cwd=cwd or "不明", files=hint, exception=EXCEPTION_ALLOWED if skip_allowed else EXCEPTION_FORBIDDEN, digest=build_digest(lines), ) parsed = parse_reply(ask_claude(prompt)) if parsed and parsed[0] == "SKIP": if skip_allowed: trace("SKIP Claudeが記録不要と判断 " + sid) return 0 trace("WARN 許可していないSKIPが返りました " + sid) parsed = None if not parsed: trace("WARN 要約が取れないのでフォールバックします " + sid) parsed = fallback(files, cwd) where = WHERE_BY_CWD.get(os.path.basename(cwd), "ClaudeCode") try: note = ensure_note(day, vault) append_entry(note, marker, when, where, *parsed) except Exception as exc: trace("ERR 書き込めませんでした: %r" % (exc,)) return 0 trace("OK " + str(note)) return 0 # --------------------------------------------------------------------- parent def parent(): raw = sys.stdin.read() trace("IN " + " ".join(raw.split())[:400]) try: event = json.loads(raw) except Exception: trace("ERR 標準入力がJSONとして読めませんでした") return 0 sid = event.get("session_id") or "" transcript = event.get("transcript_path") # 何も頼まれていないセッションは、claude を呼ぶ前に落とす _, _, _, users = read_transcript(transcript) if not users: trace("SKIP 依頼が取れないので書きません") return 0 when = now_jst() note = VAULT / (when.strftime("%Y-%m-%d") + ".md") if note.exists() and ("<!-- session:%s -->" % sid) in note.read_text(encoding="utf-8"): trace("SKIP 記録済み " + sid) return 0 job = { "session_id": sid, "transcript_path": transcript, "cwd": event.get("cwd") or "", "when": when.isoformat(), "vault": str(VAULT), } fd, job_path = tempfile.mkstemp(prefix="obsidian_log_", suffix=".json") with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(job, fh, ensure_ascii=False) # ここでは OBSIDIAN_LOG_SKIP を立てません。立てるとワーカー自身が # 冒頭のガードで止まります。ガードは ask_claude が起動する claude にだけ効かせます。 # 子プロセスの切り離し方はOS依存です。start_new_session はPOSIX専用、 # Windowsは代わりに DETACHED_PROCESS 系のフラグを使います。 detach = {"start_new_session": True} if os.name != "nt" else { "creationflags": subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP } try: with open(os.devnull, "r+b") as null: subprocess.Popen( [sys.executable, str(SELF), "--worker", job_path], stdin=null, stdout=null, stderr=null, **detach, ) except Exception as exc: trace("ERR ワーカーを起動できませんでした: %r" % (exc,)) return 0 trace("GO ワーカーを起動しました " + sid) return 0 def main(): if os.environ.get("OBSIDIAN_LOG_SKIP"): return 0 # 要約のために起動した claude 自身のセッション終了。書きません if len(sys.argv) >= 3 and sys.argv[1] == "--worker": return worker(sys.argv[2]) return parent() sys.exit(main()) ``` ## 3. ~/.claude/settings.json にSessionEndフックを追加 既存のsettings.jsonを読み、hooks.SessionEndに以下を追加してください。 既存のhooksや他の設定は壊さずマージします。ファイルが無ければ新規作成します。 `command` の値は、1で控えたpython実行系の**絶対パス**と、2で作成したスクリプトの **絶対パス**を使ってください(PATHが通っていない環境でフックが呼ばれても動くように するためです)。$HOME等の環境変数展開には頼らず、実際のフルパスを書き込みます。 例(Mac、実際は自分の環境の絶対パスに置き換える): { "hooks": { "SessionEnd": [ { "hooks": [ { "type": "command", "command": "/usr/bin/python3 \"/Users/tanaka/.claude/hooks/obsidian_log.py\"" } ] } ] } } 例(Windows、実際は自分の環境の絶対パスに置き換える。JSON内なのでバックスラッシュは 二重にします): { "hooks": { "SessionEnd": [ { "hooks": [ { "type": "command", "command": "C:\\Python312\\python.exe \"C:\\Users\\tanaka\\.claude\\hooks\\obsidian_log.py\"" } ] } ] } } ## 4. 動作確認 ダミーのSessionEndイベント(session_id, transcript_path, cwdを持つJSON)を、 3で組み立てたのと同じコマンドに標準入力で流して実行し、エラーなく終わることを 確認してください。transcript_pathは適当な既存セッションのjsonlで構いません。 vault側にファイルが作られる(またはSKIPと表示なくトレースファイルにOKが残る)ことも `~/.claude/hooks/obsidian_log.trace` で確認してください。 ## 5. 前提条件が満たせない場合 - python(3)がインストールできない - claude CLIが見つからない、`-p`オプションが動かない - vaultのフォルダを作成・書き込みできない これらに該当する場合は、セットアップを止めて理由をユーザーに伝えてください。 ## 6. 最後に 何を作成・変更したか(ファイルの絶対パス)と、次回セッション終了時から 自動で作業ログが追記されるようになる旨を、3行程度で説明してください。
実際にどう書かれるか
セットアップが終わると、Claude Codeでの作業が一段落するたびに、こういう1件が増えていきます。
## 21:40 ClaudeCode — Obsidianの作業ログ自動化を配布用に整えた
- 依頼: セッション終了時に自動で記録される仕組みを、他の人にも配りたい
- やったこと: SessionEndフックとPythonスクリプトを作り直し、Windows対応も追加
- 触ったもの: ~/.claude/hooks/obsidian_log.py、~/.claude/settings.json自分では何も打っていません。セッションが終わった数十秒後に、この4行がファイルの一番下に足されているだけです。
向いている人・向いていない人
まとめ
作業ログは、書く気力がある日にしか続きません。だったら書く工程自体を無くして、セッションが終わったら勝手にたまる状態にしてしまう。それだけで、1週間後に「先週何やってたっけ」と困ることがなくなりました。
プロンプトを貼るだけで同じものが作れるので、Obsidianで作業ログをつけている人は試してみてください。









