diff --git a/.claude/hooks/block_config_edit.py b/.claude/hooks/block_config_edit.py new file mode 100755 index 00000000..8a4196af --- /dev/null +++ b/.claude/hooks/block_config_edit.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""PreToolUse Hook: リンター・フォーマッター設定ファイルの編集をブロック + +エージェントがリンターエラーに直面した場合、コードを修正する代わりに +リンター設定を緩和してテストをパスさせようとすることがある。 +このフックはそれを防止する。 + +参考: Harness Engineering ベストプラクティス + - 「コードを修正せよ、リンター設定を変更するな」 +""" +import sys +import json + +data = json.load(sys.stdin) +tool_input = data.get("tool_input", {}) or {} +file_path = tool_input.get("file_path") or tool_input.get("path") or "" + +if not file_path: + sys.exit(0) + +# ── 保護対象のファイルパターン ────────────────────────────── +PROTECTED_BASENAMES = { + # ESLint + ".eslintrc", + ".eslintrc.js", + ".eslintrc.cjs", + ".eslintrc.json", + ".eslintrc.yml", + ".eslintrc.yaml", + "eslint.config.js", + "eslint.config.mjs", + "eslint.config.cjs", + "eslint.config.ts", + # Biome + "biome.json", + "biome.jsonc", + # Prettier + ".prettierrc", + ".prettierrc.js", + ".prettierrc.cjs", + ".prettierrc.json", + ".prettierrc.yml", + ".prettierrc.yaml", + ".prettierrc.toml", + "prettier.config.js", + "prettier.config.cjs", + "prettier.config.mjs", + # TypeScript + "tsconfig.json", + # Ruff (Python) + "ruff.toml", + # Lefthook / Husky + "lefthook.yml", + "lefthook-local.yml", + # golangci-lint + ".golangci.yml", + ".golangci.yaml", + # Clippy (Rust) - Cargo.toml の [lints] セクション + # Cargo.toml は汎用すぎるためここでは除外 + # SwiftLint + ".swiftlint.yml", + # ShellCheck + ".shellcheckrc", + # Pre-commit + ".pre-commit-config.yaml", + # Oxlint + ".oxlintrc.json", +} + +# pyproject.toml は [tool.ruff] 等のリンター設定を含むが +# 汎用的すぎるためデフォルトでは保護しない + +# ── パス判定 ─────────────────────────────────────────────── +from pathlib import PurePosixPath + +basename = PurePosixPath(file_path).name + +if basename in PROTECTED_BASENAMES: + msg = ( + f"BLOCKED: {basename} はリンター/フォーマッター設定ファイルです。\n" + f"コードを修正してください。設定を緩和してはいけません。\n" + f"FIX: リンターエラーの指示に従い、該当するソースコードを修正してください。" + ) + print(msg, file=sys.stderr) + sys.exit(2) + +sys.exit(0) diff --git a/.claude/hooks/post_edit_auto_lint.py b/.claude/hooks/post_edit_auto_lint.py new file mode 100755 index 00000000..5d4687f5 --- /dev/null +++ b/.claude/hooks/post_edit_auto_lint.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""PostToolUse Hook: ファイル編集後に自動フォーマット+リントを実行 + +Write/Edit ツールでファイルが編集された後、対応するフォーマッター・リンターを +自動実行し、残った違反を additionalContext として返す。 +エージェントは即座に自己修正できる。 + +対応言語: + - TypeScript/JavaScript: biome format + oxlint (フォールバック: prettier + eslint) + - Python: ruff check --fix + ruff format + - Shell: shellcheck (修正指示のみ) +""" +import sys +import json +import subprocess +import shutil +from pathlib import Path + +data = json.load(sys.stdin) +tool_input = data.get("tool_input", {}) or {} +file_path = tool_input.get("file_path") or tool_input.get("path") or "" + +if not file_path: + sys.exit(0) + +path = Path(file_path) +if not path.exists(): + sys.exit(0) + +suffix = path.suffix.lower() + +# ── 言語判定 ────────────────────────────────────────────── +TS_JS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} +PYTHON = {".py"} +SHELL = {".sh", ".bash"} + +if suffix not in TS_JS | PYTHON | SHELL: + sys.exit(0) + + +def run_silent(cmd: list[str]) -> None: + """コマンドをサイレント実行(失敗しても無視)""" + try: + subprocess.run(cmd, capture_output=True, timeout=30) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + +def run_capture(cmd: list[str], max_lines: int = 20) -> str: + """コマンドを実行し出力を取得""" + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + output = (result.stdout or "") + (result.stderr or "") + lines = output.strip().splitlines() + if len(lines) > max_lines: + lines = lines[:max_lines] + [f"... ({len(lines) - max_lines} more lines)"] + return "\n".join(lines) + except (subprocess.TimeoutExpired, FileNotFoundError): + return "" + + +diagnostics = "" +file_str = str(file_path) + +if suffix in TS_JS: + # Phase 1: 自動修正(サイレント) + if shutil.which("biome"): + run_silent(["biome", "format", "--write", file_str]) + run_silent(["biome", "check", "--fix", file_str]) + elif shutil.which("prettier"): + run_silent(["prettier", "--write", file_str]) + + if shutil.which("oxlint"): + run_silent(["oxlint", "--fix", file_str]) + elif shutil.which("npx"): + run_silent(["npx", "--yes", "oxlint", "--fix", file_str]) + + # Phase 2: 残った違反を取得 + if shutil.which("oxlint"): + diagnostics = run_capture(["oxlint", file_str]) + elif shutil.which("npx"): + diagnostics = run_capture(["npx", "--yes", "oxlint", file_str]) + +elif suffix in PYTHON: + if shutil.which("ruff"): + run_silent(["ruff", "check", "--fix", file_str]) + run_silent(["ruff", "format", file_str]) + diagnostics = run_capture(["ruff", "check", file_str]) + +elif suffix in SHELL: + if shutil.which("shellcheck"): + diagnostics = run_capture(["shellcheck", "-f", "gcc", file_str]) + +# ── 結果を返す ──────────────────────────────────────────── +# 問題がない場合はスキップ(oxlint の "Found 0 warnings and 0 errors" 等) +if diagnostics: + lower = diagnostics.lower() + if "0 warnings and 0 errors" in lower or "found 0 " in lower: + diagnostics = "" + # ruff: "All checks passed!" をスキップ + if "all checks passed" in lower: + diagnostics = "" + +if diagnostics and diagnostics.strip(): + output = { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": ( + f"[auto-lint] {path.name} に以下の問題が残っています。修正してください:\n" + f"{diagnostics}" + ), + } + } + json.dump(output, sys.stdout) + +sys.exit(0) diff --git a/.claude/hooks/stop_test_verification.py b/.claude/hooks/stop_test_verification.py new file mode 100755 index 00000000..4f7ee2c5 --- /dev/null +++ b/.claude/hooks/stop_test_verification.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Stop Hook: エージェント完了前にテストを実行して検証 + +エージェントが作業完了を宣言する前に、利用可能なテストスイートを +自動実行し、失敗があればエージェントに修正を促す。 + +無限ループ防止: STOP_HOOK_ACTIVE 環境変数で再帰実行を防ぐ。 +Git 変更がない場合はスキップ(新規セッションでの空実行を防止)。 +""" +import sys +import json +import subprocess +import os +from pathlib import Path + +# 無限ループ防止 +if os.environ.get("STOP_HOOK_ACTIVE") == "1": + sys.exit(0) + +os.environ["STOP_HOOK_ACTIVE"] = "1" + +# Git リポジトリのルートを取得 +try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0: + sys.exit(0) + repo_root = Path(result.stdout.strip()) +except (subprocess.TimeoutExpired, FileNotFoundError): + sys.exit(0) + +# ── Git 変更の有無を確認 ────────────────────────────────── +# ステージング済み or 未ステージングの変更がなければスキップ +try: + diff_result = subprocess.run( + ["git", "diff", "--name-only", "HEAD"], + capture_output=True, text=True, timeout=10, cwd=repo_root + ) + staged_result = subprocess.run( + ["git", "diff", "--cached", "--name-only"], + capture_output=True, text=True, timeout=10, cwd=repo_root + ) + untracked_result = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + capture_output=True, text=True, timeout=10, cwd=repo_root + ) + has_changes = bool( + (diff_result.stdout or "").strip() + or (staged_result.stdout or "").strip() + or (untracked_result.stdout or "").strip() + ) + if not has_changes: + sys.exit(0) +except (subprocess.TimeoutExpired, FileNotFoundError): + sys.exit(0) + +# ── package.json からテストスクリプトを検出 ──────────────── +pkg_json = repo_root / "package.json" +if not pkg_json.exists(): + sys.exit(0) + +try: + with open(pkg_json) as f: + pkg = json.load(f) +except (json.JSONDecodeError, OSError): + sys.exit(0) + +scripts = pkg.get("scripts", {}) + +# パッケージマネージャーを判定 +PM = "npm" +if (repo_root / "pnpm-lock.yaml").exists(): + PM = "pnpm" +elif (repo_root / "yarn.lock").exists(): + PM = "yarn" +elif (repo_root / "bun.lockb").exists() or (repo_root / "bun.lock").exists(): + PM = "bun" + +# ── テスト実行 ──────────────────────────────────────────── +TEST_SCRIPTS = ["test", "test:unit"] +test_script = None +for s in TEST_SCRIPTS: + if s in scripts: + test_script = s + break + +if not test_script: + sys.exit(0) + +try: + result = subprocess.run( + [PM, "run", test_script], + capture_output=True, text=True, timeout=300, cwd=repo_root, + env={**os.environ, "CI": "true", "STOP_HOOK_ACTIVE": "1"}, + ) +except subprocess.TimeoutExpired: + output = { + "hookSpecificOutput": { + "hookEventName": "Stop", + "additionalContext": ( + "[stop-verification] テストがタイムアウトしました(5分)。\n" + "テストを確認してから再度完了してください。" + ), + } + } + json.dump(output, sys.stdout) + sys.exit(0) + +if result.returncode != 0: + # テスト失敗 → エージェントにフィードバック + stderr_lines = (result.stderr or "").strip().splitlines() + stdout_lines = (result.stdout or "").strip().splitlines() + # 最後の 30 行を取得 + all_lines = stdout_lines + stderr_lines + tail = all_lines[-30:] if len(all_lines) > 30 else all_lines + failure_output = "\n".join(tail) + + output = { + "hookSpecificOutput": { + "hookEventName": "Stop", + "additionalContext": ( + "[stop-verification] テストが失敗しています。修正してから再度完了してください:\n" + f"{failure_output}" + ), + } + } + json.dump(output, sys.stdout) + +sys.exit(0) diff --git a/.claude/settings.json b/.claude/settings.json index b6ade7ae..add4406d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -186,6 +186,15 @@ } ] }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "bash -c 'cd \"$(git rev-parse --show-toplevel 2>/dev/null || echo .)\" && python3 .claude/hooks/block_config_edit.py'" + } + ] + }, { "matcher": "ExitPlanMode", "hooks": [ @@ -197,6 +206,15 @@ } ], "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "bash -c 'cd \"$(git rev-parse --show-toplevel 2>/dev/null || echo .)\" && python3 .claude/hooks/post_edit_auto_lint.py'" + } + ] + }, { "matcher": "Bash", "hooks": [ @@ -224,6 +242,17 @@ } ] } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "bash -c 'cd \"$(git rev-parse --show-toplevel 2>/dev/null || echo .)\" && python3 .claude/hooks/stop_test_verification.py'" + } + ] + } ] }, "enabledPlugins": { diff --git a/.devcontainer/claude-settings.json b/.devcontainer/claude-settings.json index 65f22534..9ba78ab7 100644 --- a/.devcontainer/claude-settings.json +++ b/.devcontainer/claude-settings.json @@ -296,6 +296,15 @@ } ] }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "bash -c 'cd \"$(git rev-parse --show-toplevel 2>/dev/null || echo .)\" && python3 /home/vscode/.claude/hooks/block_config_edit.py'" + } + ] + }, { "matcher": "ExitPlanMode", "hooks": [ @@ -307,6 +316,15 @@ } ], "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "bash -c 'cd \"$(git rev-parse --show-toplevel 2>/dev/null || echo .)\" && python3 /home/vscode/.claude/hooks/post_edit_auto_lint.py'" + } + ] + }, { "matcher": "Bash", "hooks": [ @@ -325,6 +343,17 @@ } ] } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "bash -c 'cd \"$(git rev-parse --show-toplevel 2>/dev/null || echo .)\" && python3 /home/vscode/.claude/hooks/stop_test_verification.py'" + } + ] + } ] } } diff --git a/README.md b/README.md index ffc2f8ec..f519062d 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,16 @@ config/ │ │ └── update-claude-code.md │ ├── hooks/ # イベント駆動の自動化スクリプト │ │ ├── README.md +│ │ ├── block_config_edit.py # リンター設定の編集防止 │ │ ├── block_dangerous_commands.py │ │ ├── block_git_no_verify.py +│ │ ├── post_edit_auto_lint.py # ファイル編集後の自動リント │ │ ├── post_git_push_ci.py │ │ ├── post_pr_ai_review.py │ │ ├── post_pr_ci_watch.py │ │ ├── pre_exit_plan_ai_review.py -│ │ └── pre_git_quality_gates.py +│ │ ├── pre_git_quality_gates.py +│ │ └── stop_test_verification.py # 完了前テスト検証 │ ├── plugins/ # プラグイン設定 │ │ ├── README.md │ │ ├── config.json @@ -617,13 +620,16 @@ For more information about LSP support in Claude Code, see [Claude Code LSP Guid Hooksは、Claude Codeの特定のイベントに自動実行されるスクリプトです。`.claude/hooks/` ディレクトリに格納されており、config-base イメージにも組み込まれるため DevContainer/Codespaces 環境でデフォルト有効です。 -| Hook | トリガー | 目的 | -| ---------------------------- | -------------------------- | ------------------------------------------- | -| `block_git_no_verify.py` | `PreToolUse(Bash)` | `--no-verify` や `HUSKY=0` の使用をブロック | -| `pre_git_quality_gates.py` | `PreToolUse(Bash)` | git commit/push 前に品質チェックを自動実行 | -| `post_git_push_ci.py` | `PostToolUse(Bash)` | git push 後に CI 状態を監視・報告 | -| `post_pr_ai_review.py` | `PostToolUse(Bash)` | PR 作成後に AI レビューを実行 | -| `pre_exit_plan_ai_review.py` | `PreToolUse(ExitPlanMode)` | プラン承認前に AI レビューを実行 | +| Hook | トリガー | 目的 | +| ---------------------------- | -------------------------- | --------------------------------------------------------- | +| `block_git_no_verify.py` | `PreToolUse(Bash)` | `--no-verify` や `HUSKY=0` の使用をブロック | +| `block_config_edit.py` | `PreToolUse(Write\|Edit)` | リンター/フォーマッター設定ファイルの編集をブロック | +| `pre_git_quality_gates.py` | `PreToolUse(Bash)` | git commit/push 前に品質チェックを自動実行 | +| `post_edit_auto_lint.py` | `PostToolUse(Write\|Edit)` | ファイル編集後に自動フォーマット+リント → 自己修正ループ | +| `post_git_push_ci.py` | `PostToolUse(Bash)` | git push 後に CI 状態を監視・報告 | +| `post_pr_ai_review.py` | `PostToolUse(Bash)` | PR 作成後に AI レビューを実行 | +| `pre_exit_plan_ai_review.py` | `PreToolUse(ExitPlanMode)` | プラン承認前に AI レビューを実行 | +| `stop_test_verification.py` | `Stop` | エージェント完了前にテスト実行 → 失敗時は修正を促す | **Quality Gates(自動検出方式):**