https://github.com/markmatsu/claude-logkeeper
ここに至るまでの環境
生成AIが登場した数年前、AIと一緒にコードを書くというのは、要するにコピペのことだった。エディタでコードを選択してチャット画面に貼り付け、返ってきた答えを読んで、AIが書いたコードをエディタに貼り戻す。それで動きはしたが、単純作業だったし、変更が複数のファイルにまたがった瞬間に破綻した。6つのファイルを選んで、それらがどう組み合わさっているかをAIが理解できるだけの説明を添えてチャット画面に貼り付ける、というのは面倒すぎて、結局やらないことも多かった。
Claude Code、そして広くエージェント型のコーディングツールは、そういう摩擦の中から生まれたのだと思う。人間がコンテキストを行ったり来たり運ぶ代わりに、ツールが直接ファイルを読んで編集してくれる。
私はそれが欲しかった。ただ、自分のノートPCで動かす気にはなれなかった。エージェントに自分のローカルのファイルシステムを自由にアクセスさせるのは落ち着かないし、自分の性格も分かっている。何かを実行する前に確認を求められても、私は中身をろくに読まずに「許可」ボタンを連打してしまうタイプだ。安全装置は使ってこそ意味があるのに、私はきっと使わない。
だから、使い捨てのクラウド環境でClaude Codeを動かすことにした。エージェントがマシンに広くアクセスするなら、そのマシンは自分の実機ではなく、捨てられるコンテナであってほしい。EC2に自分でVS Codeを立てるのは、やりたい以上のサーバー管理仕事だ。AWS Cloud9はまさにこの用途にぴったりの素晴らしいサービスだったが、今は新規利用ができない。
ちょうどよかったのがGitHub Codespacesだった。私の使い方の規模なら無料で、ブラウザ上でVS Codeが開き、環境は本当に使い捨てにできる。Claude Codeを他の選択肢より推す決め手になった細かい点が一つある。ブラウザ上のエディタでも、リモートのサインインフローがちゃんと機能するのだ。VS Codeがブラウザのタブの中で動いているとき、デスクトップ版のように認証用の新しいタブを開くことができない。Claude Codeはこのケースをきれいに処理してくれる。私はコーディングエージェントを素のコマンドラインから操作するのもあまり好きではない。せっかくフルのエディタが開いているなら、その中で作業したい。そしてClaude CodeのVS Code拡張は、まさにそのUIの中にエージェントを置いてくれる。
こうして、CodespacesでClaude Codeを使う心地よい日々が始まった。ファイルの構造をAIに説明したり、何かをチャット画面に貼り付けたりせずにコードをいじれるのは、聞いていた通り快適だった。
そこに、2つの問題が現れた。
小さいほうの問題。AIと実際に何を話したかを見返すのが、やりにくいのだ。拡張機能の会話パネルは縦長の細いカラムで、モデルの出力する文字数は多い。長いセッションを遡って「あの件、どう決めたんだっけ」を探すのは苦痛だった。
大きいほうの問題は、Claude Codeを使っていたCodespaceにCodexをインストールした日にやってきた。何が原因なのかは今も正確には分からないのだが、それ以来そのCodespaceが起動しなくなってしまった。それ自体は構わない。Codespaceが壊れるのは大した事件ではなく、新しいのを作ればいいだけだ。だが、Claude Codeの履歴も一緒に消えた。Claude Codeをローカルで動かしていれば、トランスクリプトは自分のディスクの~/.claudeに残って生き延びる。リモート環境が作り直されるのは、ローカルの視点で言えばパソコンを買い替えたのと同じだ。ログが残っているはずがなかったし、実際に残っていなかった。
この喪失が、このエッセイが本当に扱っている会話の発端だ。私はClaude(ブラウザ上のチャットアシスタントのほう)に向き合って、コンテナと一緒に蒸発してしまうClaude Codeのログをどう残すか相談した。その会話のどこかで、目標が育っていった。どうせやるなら、プロジェクトに数個のファイルを置くだけで導入できるものにしたい。あわよくば、チームで共有できて、開発者同士がお互いのコードを読むようにお互いのプロンプトを読めるようなものにできないか、と。
出来上がったのが、今は logkeeper と呼んでいる小さなツールだ。以下はチュートリアルというより、それがどう設計されていったかの記録である。私がどんな質問をし、どの答えが考えを変えさせ、もっともらしいアイデアがどこで間違いだと分かったのか、という話だ。
そもそもログはどこにあるのか
Claudeが最初に教えてくれた有用なことは、Claude Codeが状態をどこに保存しているか、だった。セッションはJSONL形式のトランスクリプトとして~/.claude/projects/<エンコードされたプロジェクトパス>/<セッションID>.jsonlに保存され、加えて全体のインデックスとして~/.claude/history.jsonlがある。claude --resumeでこれらを開き直すと、ファイルを読み込んでセッションを再構築し、続きから作業できる。
これで、データが消えた理由がすぐに分かった。Codespaceでは~/.claudeはコンテナのホームディレクトリにある。/workspacesの中ではなく、永続化されるどこかでもない。しかも二重に脆い。リビルドは/workspacesの外を消し、削除はすべてを消す。頼れるサーバー側の履歴ブラウザもない。Anthropic側に取り戻せるコピーがあるのか具体的に尋ねたが、答えは実質「ない」だった。データは保持ポリシーに従って内部的には残るかもしれないが、Codespaceのセッションをユーザーが引き出す手段はなく、claude.aiのチャット履歴はClaude Codeとは完全に別のサイロだ。ローカルのファイルこそが正本であり、だからこそバックアップが要る。
というわけで、解決策の形ははっきりした。~/.claudeを使い捨てのコンテナから、どこか永続的な場所へ、自動的に逃がすことだ。
最初の設計と、それを作り変えたセキュリティ問題
私の最初の発想は、ごく素朴なものだった。~/.claude/projectsをgit addしてリポジトリにpushする。ちょうど手元にプライベートリポジトリもあった。Vercelがデプロイに使っているやつだ。私はそれが安全か尋ねた。
その答えがプロジェクト全体を作り変えた。ここははっきり書く価値がある。logkeeperで最も重要な設計上の事実だからだ。Claude Codeのトランスクリプトは、あなたのプロンプトとモデルの返答だけではない。モデルが読んだあらゆるファイルの中身と、実行したあらゆるコマンドの出力が、そのまま含まれている。 私のプロジェクトに即して言えば、JSONLにはAWSの認証情報、Auth0のマネジメントトークン、DBの接続文字列(エージェントが目にしたものすべて)が含まれ得るし、個人情報を保持するデータベースを扱う仕事もある以上、どこかのデバッグ中にエージェントがテーブルから引っ張ってきた個人情報が入っている可能性すらある。
そこから2つの帰結が出た。一つ目。それをVercel連携のリポジトリにコミットするのは、本当にまずい考えだった。Vercelのビルド面を脇に置いても、gitの履歴は永続的で、一度シークレットが履歴に入ると、消すのは面倒な書き換え作業になる。二つ目、そしてこれがツール全体の土台になった洞察だが、危険はトランスクリプトの中でもツール呼び出しの部分に集中している。プロンプトとモデルの散文は、比較的安全に共有できる。ファイルの中身の書き出しとコマンド出力こそが、シークレットのありかなのだ。
この区別が設計の核心になった。二層構造だ。人間とアシスタントのテキストだけを含み、すべてのtool_useとtool_resultブロックを取り除いた平文の層。これはチーム内で読んで共有しても十分安全だ。そして、完全なJSONLを含む暗号化された層。これがあればclaude --resumeでの完全復元も可能だが、秘密鍵を持つ者だけがアクセスできる。
保存先については行きつ戻りつした。「1つのディレクトリをバックアップする」だけにしてはAWSは重く感じたので、非AWSの選択肢も検討した。Cloudflare R2とBackblaze B2が魅力的だったのは、S3互換なのでaws s3 syncのパイプラインがエンドポイントを変えるだけで移植できるからだ。rcloneのcryptオーバーレイが魅力的だったのは、クライアント側で暗号化できるからだ。だが私は、気に入っていた一つの制約に何度も立ち返った。全体をGitHubの中で完結させ、ファイルをコピーするだけで導入できるようにしたい。そこで保存先は、二つ目の専用のプライベートGitHubリポジトリになった。作業用リポジトリとは分離し、コードと会話ログが決して混ざらないようにする。
トリガーの選択。gitフックではなく、Claude Code自身のフック
初めのうち、トリガーはgitフックだと思い込んでいた。コミットのたびにバックアップする、と。Claudeはこれに反論し、それが二重の意味で正しかった。
一つ目の訂正は、機構上のものだった。私は/exportをgitのpost-commitフックから実行したかった。だが/exportはClaude CodeのREPL内で使う対話的なスラッシュコマンドで、外部のシェルスクリプトから呼び出す方法がない。バックアップを/exportで駆動することは、そもそも不可能だった。JSONLを直接パースするしかなかった。そして都合のいいことに、データは全部そこにある。
二つ目の訂正は概念的なもので、自分一人なら間違えていたやつだ。私の動機は「Codespaceが消えたときに履歴を失いたくない」だった。コミット時のトリガーは、これをうまく満たさない。履歴はコミットの前、探索している最中に溜まっていくし、Codespaceはいつでも死にうる。Claude Codeには独自のフックの仕組みがある(Stopは各アシスタント応答の後に、SessionEndはセッション終了時に発火する)。そして決定的なことに、フックは入力として現在のセッションのtranscript_pathを受け取る。これは私が予見できなかった微妙なバグを解決してくれる。「最新のJSONLをつかむ」という単純なやり方だと、同じプロジェクトで2つのセッションを同時に走らせたとき、それらが混ざってしまう。フックが正確なパスを渡されるおかげで、セッションが取り違えられることがない。
こうしてlogkeeperはStop+SessionEndのフックになった。Stopがあるおかげで、突然コンテナが死んでも失うのは進行中の最後の応答だけで、その前の完了したターンまではすでに保存済みだ。これは最初の問題への直接の答えになっている。
ここは、行きつ戻りつの議論がなぜ大事だったかを最もよく示す例として挙げておきたい。私は「コミットフック」を持って入り、「Claude Codeのフック、Stopで、transcript_pathを鍵にする」を持って出た。最終的な設計は、出発点からは見えなかった3つの具体的な点で、私の当初案より優れている。そしてそれらの改善はどれも、アシスタントが単に同意してくれたことからは生まれなかった。
暗号化、そして自前で暗号を設計しないという判断
暗号化の層について、私はうろ覚えで「RSA」と言った。知っていたopensslと言わなくてよかった。Claudeが間違っていた点を訂正して、より現代的な提案をしてくれた。RSAでファイルを直接暗号化することはしない。数百バイトしか暗号化できないからだ。本物のファイル暗号化はハイブリッド方式(対称鍵でデータを暗号化し、その対称鍵をRSAで包む)になる。それを手で組み立てるより、正しい道具は age だ。モダンで、小さく、ハイブリッド構成を正しくやってくれる。
ageはチームの話も、私の気に入る形にしてくれた。複数の受信者に対して同時に暗号化でき、各受信者は自分の秘密鍵で復号する。配布すべき共有の秘密が存在しない。メンバーのオンボーディングはこうだ。各自がローカルで鍵ペアを生成し、公開鍵を私に渡し、私が受信者ファイルに1行足してコミットする。オフボーディングは、その行を消すだけ。しかもageはSSHの公開鍵も受信者として受け付け、GitHubユーザーのSSH鍵はhttps://github.com/<ユーザー名>.keysで公開されているので、新しい鍵管理を一切増やしたくないチームは、すでに持っている鍵を使い回せる。
一つのルールは最後まで動かさなかった。秘密鍵は信頼できるローカルのマシンで生成し、そこから決して出さない。コミットもしないし、Codespacesシークレットにも入れない。秘密鍵をGitHubに置いたら、それは暗号文のすぐ隣に座ることになり、暗号化が無意味になる。logkeeperが扱うのは、あくまで公開鍵だけだ。
安全弁、そして潔く劣化させるという好み
いくつかの設計判断は、これが実際どう失敗するかを想像することから生まれた。
publicなリポジトリには書き込まない。 もし誰かが保存先をpublicなリポジトリに設定ミスしたら、logkeeperはトランスクリプトを公開する代わりに停止する。可能ならghで可視性を確認し、なければ未認証のAPI呼び出しにフォールバックする。そこで200が返れば、そのリポジトリがpublicだと確定するので、拒否が発動する。これは数少ない「ハードな停止」の一つだ。ここで「とりあえず保存する」は、まさに間違った本能だからだ。
鍵が無いときは、静かに失敗するのではなく劣化する。 初期のバージョンは、有効なage鍵が設定されていないとハードに失敗した。私はこれを変えるよう頼んだ。具体的な理由がある。Codespaceのターミナルに出力されるエラーは、見逃されやすい。だから今は、鍵が無くても平文のトランスクリプトはそのまま保存し、暗号化JSONLの代わりに、何が問題でどう直すかを説明した.ENCRYPTION-ERROR.txtのメモを書き出す。エラーが、誰も読まないターミナルのスクロールバックではなく、ログリポジトリそのものの中で可視化される。鍵が現れれば、次の保存でそのエラーメモは本物の暗号文に置き換わる。
セットアップのつまずきで環境を壊さない。 これは身をもって学んだので、後で触れる。
現実に押し返された部分
机上で設計するのと、実際に動かすのは別物だ。最後の一区切りは、小さくて現実的な失敗の連続だった。実際に走らせてみないと見つからない類の失敗で、それぞれが最終的なツールかドキュメントに痕跡を残した。
universalイメージが痛いほど遅かった。 最初のdevcontainerは、GitHubの全部入り環境であるuniversalベースイメージを使っていた。初回ビルドは数GBのpullと展開に10分近くを費やした。ビルドバーが「preparing layers for inline cache」でそれだけ長く止まっているのを見て、私は固まったと確信した。固まってはいなかった。あの工程は、次回以降のビルドを速くするためにBuildKitがレイヤーをキャッシュしているのだ。初回に待たされるにしては、なかなか馬鹿げた話ではある。修正は、軽量なbase:ubuntuイメージに切り替え、必要なものだけをdevcontainerのfeatureに入れさせることだった(Node.js、Claude CodeのCLIと拡張、gh、加えてageとjq)。これで初回ビルドは数分に縮んだ。瞬時ではない。そのことはREADMEに必ず書くようにした。無限定の「速い」に対して「数分」というのは、それ自体が一種の嘘だからだ。
壊れたサードパーティのaptリポジトリが、セットアップスクリプト全体を道連れにした。 軽量イメージが動くようになると、今度はビルドが最後の最後、私のsetup.shで失敗した。犯人は、ベースイメージに焼き込まれた期限切れのyarn署名鍵で、これのせいでapt-get updateが非ゼロの終了コードを返した。私のスクリプトは冒頭にset -euo pipefailを置いていた(一般には良い作法だ)。だからapt-get updateが非ゼロを返した瞬間、スクリプト全体がageとjqをインストールする前に中断した。この教訓はルールへと一般化した。セットアップの便宜スクリプトは、些細な何かのせいで環境を失敗状態に置いてはならない。書き直したものはset -eを外し、apt-get updateの騒がしさを許容し、失敗したら未署名リポジトリを許容するフォールバックで再試行し、どのツールが(もしあれば)まだ足りないかを明確に報告し、常に0で終了する。
Codespacesシークレットは、リポジトリへのアクセスを許可するまで注入されない。 すべてがビルドされるようになっても、setup.shはなおLOGKEEPER_REPO env var is not setと報告した。私は3つのシークレットを全部作っていた。見落としていたのはこれだ。作りたてのCodespacesシークレットは「Repository access」が空で(「0 repositories」と表示される)、リポジトリへのアクセスが空のシークレットは、どのCodespaceにも注入されない。各シークレットに対して、自分のプロジェクトリポジトリへのアクセスを明示的に許可する必要があり、しかもシークレットはコンテナ起動時にしか注入されないので、その後にCodespaceを再起動しなければならない。そうして初めてecho $LOGKEEPER_REPOが値を返した。これは、後から見れば当たり前で、その瞬間には見えない、まさにそういう類の問題だ。だから今はREADMEで目立つように注意喚起している。
そして、正しく設定されたトークンが、それでもpushできなかった。 これが最後の、そして最も学びの多い失敗だった。環境変数は存在が確認できていた。fine-grained PATの権限も正確だった。ログリポジトリへのContents読み書き、それ以上でも以下でもない。それでもpushは「書き込めない」で失敗した。手がかりはエラーの一行上にあった。warning: You appear to have cloned an empty repository。問題はトークンではなかった。私のスクリプトが、トークンをgitに渡していなかったのだ。cloneもpushも素のhttps://github.com/...URLを使っていて、それはgitの周囲の認証情報(作業用リポジトリにスコープされたもので、ログリポジトリ用ではない)に頼る。gh auth statusが成功していたのは目くらましだった。それはghの認証であって、gitの認証ではないからだ。修正は、GH_TOKEN(またはgh auth token)から認証情報を埋め込んだリモートURL、https://x-access-token:${GH_TOKEN}@github.com/...を組み立てることと、空リポジトリへの初回pushのケースを上流ブランチの設定で扱うことだった。これが、実際のトランスクリプトをついにログリポジトリへ着地させた変更だった。
そして、その修正が今度は普通のgit pushを壊した。 トークンをgitに渡す一番素直な方法は、PATをGH_TOKENという名前のCodespacesシークレットに入れることだ。それは動くし、同時に罠でもある。GH_TOKENは、ghもgitもあらゆる操作で自動的に参照しにいく名前なのだ。だからそれを設定した瞬間、作業リポジトリへの日常的なgit pushが、ログリポジトリ用のPATで認証しようとし始めた。そのPATはログリポジトリにしかスコープされていない。自分のコードのpushが「Write access not granted」で失敗し、しかも文句を言われているトークンは、私が意図的に最小の権限だけを与えたものだった。一つのリポジトリに厳しく絞り込んだはずの認証情報が、いつのまにか全リポジトリの認証を乗っ取っていた。
抜け道は3つあり、区別しておく価値がある。魅力的に見えるものが間違いだからだ。一つ目、PATの対象に作業リポジトリも加える。これは症状を消すが、fine-grainedトークンを使う意味そのものだった最小権限の性質を手放すことになる。二つ目、毎回GH_TOKEN= git pushと前置きする。これは修正ではない。三つ目、魔法の名前を使うのをやめる。PATをLOGKEEPER_GH_TOKENに入れ、フックにはその変数を優先して読ませ、ghとgitにはCodespaces標準の認証情報を使わせておく。今のフックはLOGKEEPER_GH_TOKENを最初に読み、それが無いときだけGH_TOKENとGITHUB_TOKENにフォールバックする。日常のpushはCodespace自身のトークンに戻り、logkeeperの認証情報は一つの仕事だけをする。ここから引き出せる一般的な教訓は、環境変数とはマシン上の全ツールと共有している名前空間であり、いくつかの名前はすでに予約済みだ、ということだ。
作らないと決めたもの
記録しておく価値のあることが一つある。検討して却下した変更だ。却下したこと自体が、一つの設計判断だったからだ。
logkeeperの失敗は、事実上、静かに起きる。トークンが期限切れになっても、シークレットがリポジトリへのアクセスを失っても、ログリポジトリの名前が変わっても、フックはエラーをstderrに書き、それは表に出てこない。Claude Codeはフックのstderrを表示してくれるが、それはトランスクリプト表示か--debugの中だけで、VS Code拡張が動くブラウザ上のCodespaceでは、現実的にそこを見ることはまずない。実際に気づく症状は、ログリポジトリに新しいファイルが増えない、というそれだけだ。バックアップツールにとって、これは悪い性質である。自分が見ていないときに動いていることこそが、その全仕事なのだから。
Claude Codeにはこのための正式な経路がある。フックはstdoutにsystemMessageフィールドを持つJSONを返すことができ、Claude Codeはそれを表示してくれる。だから私はこれを使うべきか尋ねた。そして返ってきた答えが、私の考えを変えた。
問題は、Claude CodeがフックのstdoutをJSONとしてパースすることだ。今のlogkeeperはすべてのメッセージをstderrに書き、stdoutには何も出さない。つまりstdoutは不活性で、何も壊しようがない。systemMessageを採用すると、git、age、jq、curlを呼び出すスクリプトの中で、stdoutがパースされる経路になる。そしてそれらのどれかが予期せぬ一行をstdoutに吐いた瞬間、JSONは壊れ、セッションが壊れる。これは既知の失敗モードで、おしゃべりなシェルのプロファイル一つでも起こりうる。私は「静かな失敗」を、「バックアップツールが、バックアップすべきセッションを破壊する」という、はるかに大きな失敗と引き換えにすることになる。おまけに、フックの出力がそもそもUIに届かないという報告も上がっていて、この改善が私の気にしている環境で実を結ぶ保証すらない。
そういうわけで、logkeeperはstdoutを空のまま保ち、静かに失敗し続け、READMEにはそのことを平明な言葉で書いた。フックを手で実行して本当のエラーを見るための一行コマンドも添えて。すっきりしない答えではある。だが、目に見える不便を、目に見えないリスクと交換する修正を出荷するより、これを既知の限界として書き留めるほうが誠実だ。
うまくいったときの姿
最終形は、最初に望んでいた通りのものになった。.claude/と.devcontainer/をプロジェクトに置き、3つのCodespacesシークレット(保存先リポジトリ、自分のage公開鍵、そしてPAT)を設定する。それだけで、Claude Codeのセッションはすべて、二層でプライベートリポジトリに自らをバックアップする。会話だけを保持した平文のトランスクリプトはGitHub上で直接読め、安全にブラウズできる。暗号化された.jsonl.ageは、鍵を持つ者のために完全なセッションを保持する。そしてindex.jsonが、将来のビューアのためにそれらを追跡する。フックの設定はコミットされているので、pullしたチームメンバー全員に適用される。各自が自分の鍵と自分のシークレットを足せば、彼らのセッションもバックアップされ始める。
最初にうまくいったトランスクリプトが、設計が成立していることを裏づけた。ファイルにはプロンプトと返答が入っていて、それ以外は何もなかった。コマンド出力とファイルの書き出し、つまり認証情報を漏らしうる部分は、そこにはなく、もう一方の層で暗号化されて座っていた。
どうやって作ったか、についての覚書
作り方について正直に書いておきたい。それがここでの本当の主題だからだ。私はAIに仕様を渡して完成したツールを受け取ったのではない。私が持ち込んだのは、大まかな目標と、いくつかの間違った思い込みだった。そして設計は、意見の相違を通じて良くなった。RSA直接暗号はageによるハイブリッド暗号になった。コミットフックはStopでのClaude Codeフックになった。「ログをただgit addする」は、平文と暗号化の二層設計になった。アシスタントが、私の頼み事を親切にこなす代わりに、トランスクリプトにはシークレットが含まれるとはっきり告げたからだ。ハードな失敗は、潔い劣化になった。GH_TOKENはLOGKEEPER_GH_TOKENになった。一番素直な名前は、マシン上の他のあらゆるツールにすでに予約されていたからだ。そして、エラーをちゃんと表示させようという提案は、それが直す失敗より悪い失敗を持ち込むという理由で却下された。
最後のものは強調しておきたい。systemMessageを足すリスクは何かと私が尋ねたとき、役に立った答えは実装手順のリストではなかった。「やめておけ、理由はこうだ」という、私がたった今頼んだことに対する反対勧告だった。あらゆる要求を実行すべき命令として扱うアシスタントなら、喜んでそれを作っただろう。そして私は、バックアップすべきセッションを壊しうるバックアップツールを出荷していたはずだ。
一方で現実世界の失敗の連鎖(遅いイメージ、壊れたaptリポジトリ、許可されていないシークレット、gitに一度も渡されなかったトークン、そして今度はすべてに渡されてしまったトークン)は、当て推量ではなく、実際のエラー出力を読むことで、一つずつ診断された。
改善はどれも、同意からは生まれなかった。私が評価して、受け入れるか押し返すかできる訂正から生まれた。これが、私が実際に生産的だと感じたAIとの働き方だ。委任でもなく、オートコンプリートでもなく、あなたより多くのドキュメントを読んでいて、あなたの計画が間違っているときにはそう言ってくれる相手との、議論。
logkeeperは小さなツールだ。だが、そのログが残す価値のあるものである理由は、それを作る過程を書き留める価値があった理由と同じだ。AIと働くことの面白い部分は、AIが打ち込むコードではなく、そこに至るために一緒にやる推論のほうにある。そしてその推論こそが、コンテナが死ぬときに消えていたものなのだ。
ツールは github.com/markmatsu/claude-logkeeper にある(MIT)。完全な仕様と、上で述べたすべての失敗モードを記した DESIGN.md を同梱してある。AIに渡せば、私と同じ失敗を繰り返すことなくツールを改変できるように書いてある。