Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .claude/hooks/block_config_edit.py
Original file line number Diff line number Diff line change
@@ -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)
116 changes: 116 additions & 0 deletions .claude/hooks/post_edit_auto_lint.py
Original file line number Diff line number Diff line change
@@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not drop lint errors when warning count is zero

The success filter "found 0 " is too broad and clears diagnostics even when errors remain (for example, summaries like Found 0 warnings and N errors). In that case additionalContext is never emitted, so the auto-fix loop silently misses real lint failures and can allow completion with unresolved issues.

Useful? React with 👍 / 👎.

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)
131 changes: 131 additions & 0 deletions .claude/hooks/stop_test_verification.py
Original file line number Diff line number Diff line change
@@ -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:
Comment on lines +94 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch missing package manager executable in Stop hook

This block only handles TimeoutExpired, but subprocess.run([PM, "run", ...]) can also raise FileNotFoundError when the lockfile-selected package manager (pnpm/yarn/bun) is not installed in the current environment. That causes the hook to crash instead of gracefully skipping or reporting, which can interrupt the completion flow.

Useful? React with 👍 / 👎.

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)
29 changes: 29 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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": [
Expand Down Expand Up @@ -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": {
Expand Down
Loading
Loading