Created
November 20, 2025 03:32
-
-
Save hitode909/cb4ebec36add3aae048967ccdbdf64e4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| # Claude Codeとの会話ログからコーディングルールを抽出してグローバルCLAUDE.mdに追記するスクリプト | |
| # | |
| # ## 目的 | |
| # Claude Codeとの過去の会話ログを分析し、コーディング指摘やベストプラクティスを | |
| # 自動的に抽出してルール化することで、学習効果を継続・蓄積する | |
| # | |
| # ## アプローチ | |
| # 1. 現在のディレクトリから対応するClaude Codeプロジェクトの会話ログを特定 | |
| # 2. 会話ログからユーザーメッセージのみを抽出(ツール実行やシステムメッセージを除外) | |
| # 3. 抽出したメッセージを Claude Code にワンショットで送信してルール抽出を依頼 | |
| # 4. JSON形式でレスポンスを受け取り、構造化されたルールとしてCLAUDE.mdに追記 | |
| # 5. 重複処理を避けるため、処理済みファイルをマークして管理 | |
| # | |
| # ## 実行方法 | |
| # - 通常実行: collect-claude-rules.rb | |
| # - テストモード(最新1件のみ): TEST_MODE=1 collect-claude-rules.rb | |
| # | |
| # ## 出力先 | |
| # ~/.claude/CLAUDE.md にルールが追記される | |
| require 'json' | |
| require 'pathname' | |
| require 'shellwords' | |
| require 'securerandom' | |
| require 'logger' | |
| # 設定 | |
| # 現在のワーキングディレクトリからプロジェクト名を動的に取得 | |
| def get_claude_project_name | |
| cwd = Pathname.new(Dir.pwd) | |
| # Claude Codeのプロジェクト名形式: パスの'/', '.', '_'を'-'に変換 | |
| project_name = cwd.to_s.gsub('/', '-').gsub('.', '-').gsub('_', '-') | |
| project_name | |
| end | |
| CONVERSATION_DIR = Pathname.new(ENV['HOME']) / '.claude' / 'projects' / get_claude_project_name | |
| GLOBAL_CLAUDE_MD = Pathname.new(ENV['HOME']) / '.claude' / 'CLAUDE.md' | |
| CLAUDE_CODE_BIN = 'claude' | |
| # スクリプト専用のセッションID(実行ごとに生成) | |
| SCRIPT_SESSION_ID = SecureRandom.uuid | |
| # loggerをstderrに設定 | |
| logger = Logger.new($stderr) | |
| logger.level = Logger::INFO | |
| logger.formatter = proc do |severity, datetime, progname, msg| | |
| "[#{severity}] #{msg}\n" | |
| end | |
| # JSON構造のみでツール/サブエージェント実行をスキップするかどうか判定 | |
| def should_skip_content?(data) | |
| # ツール実行・結果 | |
| return true if data['type'] == 'tool_use' | |
| return true if data['type'] == 'tool_result' | |
| # assistantメッセージで tool_use が含まれる場合 | |
| if data['type'] == 'assistant' && data['content'].is_a?(Array) | |
| return true if data['content'].any? { |c| c['type'] == 'tool_use' } | |
| end | |
| # userメッセージのみを処理対象とする | |
| message = data['message'] | |
| return true unless message && message['role'] == 'user' | |
| # isMeta フラグがある場合はツール説明の可能性が高い | |
| return true if data['isMeta'] == true | |
| false | |
| end | |
| # 会話ログファイルを取得 | |
| conversation_files = Dir.glob(CONVERSATION_DIR / '*.jsonl') | |
| .sort_by { |f| File.mtime(f) } | |
| .reverse | |
| # TEST_MODEなら最新1件に絞る | |
| if ENV['TEST_MODE'] | |
| conversation_files = conversation_files.take(1) | |
| end | |
| puts "Found #{conversation_files.size} conversation logs" | |
| # 各会話ログからルールを抽出 | |
| conversation_files.each_with_index do |file, index| | |
| puts "\n[#{index + 1}/#{conversation_files.size}] Processing: #{File.basename(file)}" | |
| # すでに処理済みかチェック | |
| file_id = File.basename(file, '.jsonl') | |
| if File.exist?(GLOBAL_CLAUDE_MD) | |
| claude_md_content = File.read(GLOBAL_CLAUDE_MD) | |
| if claude_md_content.include?("<!-- Extracted from conversation #{file_id} -->") | |
| logger.info "Already processed: #{file_id}, skipping" | |
| puts " → Already processed, skipping" | |
| next | |
| end | |
| end | |
| # 会話ログを読み込んでユーザーメッセージを抽出 | |
| begin | |
| user_messages = [] | |
| File.foreach(file) do |line| | |
| data = JSON.parse(line) | |
| # JSONの構造でツール/サブエージェント関連をスキップ | |
| next if should_skip_content?(data) | |
| if data['message'] && data['message']['role'] == 'user' | |
| content = data['message']['content'] | |
| # contentは文字列の場合と配列の場合がある | |
| if content.is_a?(String) | |
| # システムメッセージ(<で始まる)を除外 | |
| user_messages << content unless content.start_with?('<') | |
| elsif content.is_a?(Array) | |
| text_parts = content.select { |c| c['type'] == 'text' }.map { |c| c['text'] } | |
| # システムメッセージを除外 | |
| filtered_parts = text_parts.reject { |t| t.start_with?('<') } | |
| user_messages << filtered_parts.join("\n") unless filtered_parts.empty? | |
| end | |
| end | |
| end | |
| # 意味のあるメッセージがない場合はスキップ | |
| meaningful_messages = user_messages.reject { |m| | |
| m.strip.empty? || | |
| m.include?('[Request interrupted') || | |
| m.strip.length < 20 || # 短すぎるメッセージを除外 | |
| m =~ /^[a-zA-Z0-9\s\-_\.]*$/ || # 英数字のみのメッセージを除外 | |
| m.strip.match?(/^(ok|OK|thanks|ありがとう|Warmup|はい|yes|no)$/i) # 単語レベルの応答を除外 | |
| } | |
| logger.info "Processing #{File.basename(file)}: #{meaningful_messages.size} messages" | |
| meaningful_messages.each_with_index do |msg, i| | |
| logger.info "[#{i+1}] #{msg}" | |
| end | |
| next if meaningful_messages.empty? | |
| # ワンショットでClaude Codeにルール抽出を依頼(JSON形式で構造化) | |
| prompt = <<~PROMPT | |
| IMPORTANT: あなたは自動化スクリプトから呼ばれています。ユーザーへの質問や確認は一切不要です。 | |
| 以下のユーザーメッセージ群から、コーディングルールとして抽出できそうな普遍的な指摘を見つけてください。 | |
| 特に注目する指摘の種類: | |
| - コーディングスタイルに関する指摘(変数名、制御構文、演算子の使い方など) | |
| - 使うべき関数や定数の指摘 | |
| - テストの書き方に関する指摘 | |
| - 周辺コードとの一貫性に関する指摘 | |
| **必ず以下のJSON形式で出力してください:** | |
| ```json | |
| { | |
| "rules": [ | |
| { | |
| "name": "ルール名", | |
| "description": "説明文" | |
| } | |
| ] | |
| } | |
| ``` | |
| ルールが見つからない場合は以下を出力してください: | |
| ```json | |
| { "rules": [] } | |
| ``` | |
| 前置きや説明は不要です。JSONのみ出力してください。 | |
| ユーザーメッセージ: | |
| #{meaningful_messages.join("\n---\n")} | |
| PROMPT | |
| # Claude Codeをワンショットで実行 | |
| # -p: 非インタラクティブモード | |
| # --session-id: 実行ごとに新しいセッションIDを使用(既存セッションとの衝突を避ける) | |
| # 標準入力経由でプロンプトを渡す | |
| session_id = SecureRandom.uuid | |
| cmd = "#{CLAUDE_CODE_BIN} -p --session-id #{session_id} 2>&1" | |
| logger.info "Executing: #{cmd}" | |
| result = IO.popen(cmd, 'r+') do |io| | |
| io.write(prompt) | |
| io.close_write | |
| io.read | |
| end | |
| logger.info "Claude response: #{result}" | |
| # JSONレスポンスをパース | |
| begin | |
| # JSONブロックを抽出(```json〜```の部分) | |
| json_match = result.match(/```json\s*\n(.*?)\n```/m) | |
| json_str = json_match ? json_match[1] : result.strip | |
| response_data = JSON.parse(json_str) | |
| rules = response_data['rules'] || [] | |
| if rules.empty? | |
| puts " → No rules found" | |
| else | |
| puts " → Found #{rules.size} rules, appending to #{GLOBAL_CLAUDE_MD}" | |
| # マークダウン形式に変換 | |
| markdown_rules = rules.map { |rule| "- **#{rule['name']}**: #{rule['description']}" }.join("\n") | |
| puts markdown_rules | |
| # グローバルCLAUDE.mdに追記 | |
| File.open(GLOBAL_CLAUDE_MD, 'a') do |md| | |
| md.puts "\n<!-- Extracted from conversation #{File.basename(file, '.jsonl')} -->" | |
| md.puts markdown_rules | |
| end | |
| end | |
| rescue JSON::ParserError => e | |
| logger.error "Failed to parse JSON response: #{e.message}" | |
| logger.error "Raw response: #{result}" | |
| puts " → Failed to parse JSON response, skipping" | |
| end | |
| # このセッションのログファイルをすぐに削除(セッションの再利用を防ぐ) | |
| session_log_file = CONVERSATION_DIR / "#{session_id}.jsonl" | |
| if File.exist?(session_log_file) | |
| File.delete(session_log_file) | |
| puts " → Cleaned up session log: #{session_id}" | |
| end | |
| rescue JSON::ParserError => e | |
| puts " → Skipped (JSON parse error: #{e.message})" | |
| rescue => e | |
| puts " → Error: #{e.message}" | |
| end | |
| # レート制限を考慮して少し待つ | |
| sleep 2 | |
| end | |
| puts "\n✅ Completed!" | |
| puts "Updated: #{GLOBAL_CLAUDE_MD}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment