diff --git a/.claude/hooks/block_dangerous.py b/.claude/hooks/block_dangerous.py deleted file mode 100755 index 8b26a08..0000000 --- a/.claude/hooks/block_dangerous.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -""" -Claude Code 훅: 위험한 명령어 차단 -데이터 손실을 유발할 수 있는 파괴적인 git 및 파일 작업을 방지합니다. -종료 코드: - 0 = 명령어 허용 - 2 = 명령어 차단 (stderr가 에러 메시지로 표시됨) -""" -import json -import re -import sys - -# 🚫 차단할 명령어 패턴 리스트 -BLOCKED_PATTERNS = [ - # Git 히스토리 파괴 방지 - (r"git\s+reset\s+--hard", "git reset --hard는 커밋되지 않은 작업을 삭제합니다"), - (r"git\s+push\s+.*--force", "git push --force는 원격 히스토리를 덮어씁니다"), - (r"git\s+push\s+.*-f\b", "git push -f는 원격 히스토리를 덮어씁니다"), - - # Git 작업 디렉토리 파괴 방지 - (r"git\s+clean\s+-.*f", "git clean -f는 추적되지 않은 파일을 영구 삭제합니다"), - (r"git\s+checkout\s+\.\s*$", "git checkout .은 모든 커밋되지 않은 변경사항을 삭제합니다"), - (r"git\s+restore\s+\.\s*$", "git restore .은 모든 커밋되지 않은 변경사항을 삭제합니다"), - (r"git\s+stash\s+drop", "git stash drop은 스태시된 변경사항을 영구 삭제합니다"), - (r"git\s+stash\s+clear", "git stash clear는 모든 스태시를 삭제합니다"), - - # 시스템/파일 파괴 방지 - (r"\brm\s+-rf\s+/\s*(;|&&|\|\||$)", "rm -rf /는 매우 위험합니다"), - (r"\brm\s+-rf\s+~", "rm -rf ~는 홈 디렉토리를 삭제합니다"), - (r"\brm\s+-rf\s+\.\.", "rm -rf ..은 상위 디렉토리를 삭제할 수 있습니다"), - (r"\brm\s+-rf\s+\*", "rm -rf *는 위험합니다"), - - # 데이터베이스 파괴 방지 - (r"DROP\s+DATABASE", "DROP DATABASE는 파괴적입니다"), - (r"DROP\s+TABLE", "DROP TABLE은 파괴적입니다"), - (r"TRUNCATE\s+TABLE", "TRUNCATE TABLE은 모든 데이터를 삭제합니다"), -] - - -def main(): - try: - # Claude로부터 입력받은 JSON 파싱 - data = json.load(sys.stdin) - except json.JSONDecodeError: - sys.exit(0) # 파싱 실패시 기본적으로 허용 - - tool_name = data.get("tool_name", "") - if tool_name != "Bash": - sys.exit(0) - - command = data.get("tool_input", {}).get("command", "") - if not command: - sys.exit(0) - - # 패턴 매칭 검사 - for pattern, reason in BLOCKED_PATTERNS: - if re.search(pattern, command, re.IGNORECASE): - print(f"🚫 [Safety Hook] 차단됨: {reason}", file=sys.stderr) - print(f"", file=sys.stderr) - print(f"시도된 명령어: {command}", file=sys.stderr) - print(f"정말 실행이 필요하다면, 터미널에서 직접 실행하세요.", file=sys.stderr) - sys.exit(2) # 종료 코드 2 = 차단 - - sys.exit(0) # 허용 - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/layer_doc_reminder.py b/.claude/hooks/layer_doc_reminder.py deleted file mode 100644 index cd615a3..0000000 --- a/.claude/hooks/layer_doc_reminder.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -""" -Claude Code 훅: 레이어 문서 리마인더 -해당 레이어 파일을 세션 내 처음 수정할 때, 관련 문서를 먼저 읽도록 차단합니다. -같은 세션에서 두 번째 수정부터는 통과합니다. - -종료 코드: - 0 = 허용 - 2 = 차단 (문서를 먼저 읽으라는 메시지) -""" -import json -import os -import sys - -REMINDER_DIR = "/tmp/claude_layer_reminders" - -# 파일 경로 패턴 → 읽어야 할 문서 -LAYER_DOCS = { - "domain": "docs/layers/domain.md", - "application": "docs/layers/application.md", - "infrastructure": "docs/layers/infrastructure.md", - "presentation": "docs/layers/presentation.md", -} - -SECURITY_DOC = "docs/spring-security-7.md" - - -def get_marker_path(session_id, key): - return os.path.join(REMINDER_DIR, f"{session_id}_{key}") - - -def should_remind(session_id, key): - return not os.path.exists(get_marker_path(session_id, key)) - - -def mark_reminded(session_id, key): - os.makedirs(REMINDER_DIR, exist_ok=True) - with open(get_marker_path(session_id, key), "w") as f: - f.write("") - - -def detect_layer(file_path): - for layer in LAYER_DOCS: - if f"/{layer}/" in file_path: - return layer - return None - - -def is_security_related(file_path): - return "common/config" in file_path and "security" in file_path.lower() - - -def main(): - try: - data = json.load(sys.stdin) - except json.JSONDecodeError: - sys.exit(0) - - session_id = data.get("session_id", "unknown") - file_path = data.get("tool_input", {}).get("file_path", "") - if not file_path: - sys.exit(0) - - # docs/ 자체를 수정하는 경우는 무시 - if "/docs/" in file_path: - sys.exit(0) - - # test 파일은 무시 (src/test/ 경로 또는 Test.kt 접미사) - if "/src/test/" in file_path or file_path.endswith("Test.kt"): - sys.exit(0) - - messages = [] - - # 레이어 문서 리마인더 - layer = detect_layer(file_path) - if layer and should_remind(session_id, layer): - mark_reminded(session_id, layer) - doc = LAYER_DOCS[layer] - messages.append(f"[{layer}] 이 레이어 첫 수정입니다. 먼저 {doc} 를 읽으세요.") - - # Security 리마인더 - if is_security_related(file_path) and should_remind(session_id, "security"): - mark_reminded(session_id, "security") - messages.append(f"[security] Security 설정 첫 수정입니다. 먼저 {SECURITY_DOC} 를 읽으세요.") - - if messages: - for msg in messages: - print(msg, file=sys.stderr) - sys.exit(2) # 차단 → 문서 읽은 뒤 재시도 - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/plan_completion_guard.py b/.claude/hooks/plan_completion_guard.py deleted file mode 100644 index 4b7fdcf..0000000 --- a/.claude/hooks/plan_completion_guard.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -""" -Claude Code 훅: PR 생성 / push 시 미완료 plan.md 차단 - -gh pr create 또는 git push 명령 실행 전, -docs/plan/**/plan.md에 미완료 항목(- [ ])이 있으면 차단합니다. - -미완료 계획이 머지되는 것을 방지하여, -다른 작업의 work_plan_enforcer / plan_update_reminder 오작동을 예방합니다. - -종료 코드: - 0 = 허용 - 2 = 차단 -""" -import json -import os -import re -import sys - -PROJECT_DIR = os.environ.get("CLAUDE_PROJECT_DIR", "") -PLAN_BASE = os.path.join(PROJECT_DIR, "docs", "plan") if PROJECT_DIR else "" - -REQUIRED_PLAN_FILES = ["plan.md", "checklist.md"] - -# gh pr create 또는 git push 감지 -PR_CREATE_RE = re.compile(r"\bgh\s+pr\s+create\b") -GIT_PUSH_RE = re.compile(r"\bgit\s+push\b") - - -def find_incomplete_plans(): - """미완료 항목이 있는 plan.md 경로 목록 반환.""" - if not PLAN_BASE or not os.path.isdir(PLAN_BASE): - return [] - - try: - entries = os.listdir(PLAN_BASE) - except OSError: - return [] - - incomplete = [] - for entry in entries: - plan_dir = os.path.join(PLAN_BASE, entry) - if not os.path.isdir(plan_dir): - continue - - if not all( - os.path.isfile(os.path.join(plan_dir, f)) for f in REQUIRED_PLAN_FILES - ): - continue - - plan_md = os.path.join(plan_dir, "plan.md") - try: - with open(plan_md) as f: - content = f.read() - except OSError: - continue - - if "- [ ]" in content: - incomplete.append(plan_md) - - return incomplete - - -def main(): - try: - data = json.load(sys.stdin) - except json.JSONDecodeError: - sys.exit(0) - - tool_name = data.get("tool_name", "") - tool_input = data.get("tool_input", {}) - - if tool_name != "Bash": - sys.exit(0) - - command = tool_input.get("command", "") - - if not PR_CREATE_RE.search(command) and not GIT_PUSH_RE.search(command): - sys.exit(0) - - incomplete = find_incomplete_plans() - if not incomplete: - sys.exit(0) - - plans = "\n".join(f" - {p}" for p in incomplete) - print( - f"[plan-guard] 미완료 plan.md가 있어 PR 생성/push를 차단합니다.\n" - f"다음 plan.md의 모든 항목을 완료([x])하거나 불필요한 계획을 정리하세요:\n" - f"{plans}", - file=sys.stderr, - ) - sys.exit(2) - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/plan_update_reminder.py b/.claude/hooks/plan_update_reminder.py deleted file mode 100644 index 4686e48..0000000 --- a/.claude/hooks/plan_update_reminder.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 -""" -Claude Code 훅: 작업 계획 문서 업데이트 리마인더 -TaskCreate/TaskUpdate 호출 후 plan 문서 관련 피드백을 제공합니다. - -- TaskCreate 후: plan 디렉토리·문서 생성 여부 확인 리마인드 -- TaskUpdate(status=completed) 후: plan.md 체크 표시 업데이트 리마인드 - -예외 (work-planning-rules.md 기준): -- 문서 작성·수정 관련 태스크 -- 설정 파일 변경 관련 태스크 -- 빌드·CI/CD 설정 관련 태스크 -""" -import json -import re -import sys -import glob -import os - -PROJECT_DIR = os.environ.get("CLAUDE_PROJECT_DIR", "") -PLAN_BASE = os.path.join(PROJECT_DIR, "docs", "plan") if PROJECT_DIR else "" - -REQUIRED_PLAN_FILES = ["plan.md", "checklist.md"] - -# 비코드(예외) 작업 판별 패턴 (work-planning-rules.md 예외 기준) -# 태스크 subject/description에 이 문자열이 포함되면 계획 문서 리마인더를 건너뜀 -EXEMPT_FILE_PATTERNS = [ - "/docs/", "docs/", - "/.claude/", ".claude/", - "README", "CLAUDE.md", - ".gradle.kts", ".gradle", - ".yml", ".yaml", - ".properties", ".toml", ".xml", - "Dockerfile", "docker-compose", - ".github/workflows", -] - -EXEMPT_KEYWORD_RE = re.compile( - r"문서|설정\s*파일|빌드|CI/?CD|배포|deploy|hook|훅", - re.I, -) - - -def is_exempt_task(tool_input): - """태스크가 작업 계획 프로세스 예외 대상인지 판별. - - subject/description에 비코드 작업(문서, 설정, 빌드) 관련 패턴이 있으면 예외. - """ - subject = tool_input.get("subject", "") - description = tool_input.get("description", "") - text = f"{subject} {description}" - - for pattern in EXEMPT_FILE_PATTERNS: - if pattern in text: - return True - - if EXEMPT_KEYWORD_RE.search(text): - return True - - return False - - -def find_active_plan_dirs(): - """활성 작업 계획 디렉토리 목록 반환. - - 활성 계획 조건: plan.md, checklist.md 2종 모두 존재 + plan.md에 미완료 항목. - """ - if not PLAN_BASE or not os.path.isdir(PLAN_BASE): - return [] - dirs = [] - try: - entries = os.listdir(PLAN_BASE) - except OSError: - return [] - for entry in entries: - plan_dir = os.path.join(PLAN_BASE, entry) - if not os.path.isdir(plan_dir): - continue - # 2종 문서 존재 확인 - if not all( - os.path.isfile(os.path.join(plan_dir, f)) for f in REQUIRED_PLAN_FILES - ): - continue - plan_md = os.path.join(plan_dir, "plan.md") - with open(plan_md) as f: - content = f.read() - if "- [ ]" in content: - dirs.append(plan_dir) - return dirs - - -def handle_task_create(tool_input): - if is_exempt_task(tool_input): - return None - - subject = tool_input.get("subject", "") - active_dirs = find_active_plan_dirs() - if not active_dirs: - msg = ( - f"[plan] TaskCreate 감지: \"{subject}\"\n" - f"docs/plan/{{작업명}}/ 에 plan.md, checklist.md 를 생성했는지 확인하세요. " - f"(docs/work-planning-rules.md 참고)" - ) - return msg - return None - - -def handle_task_update(tool_input): - if is_exempt_task(tool_input): - return None - - status = tool_input.get("status", "") - if status != "completed": - return None - - active_dirs = find_active_plan_dirs() - if active_dirs: - plan_list = ", ".join( - os.path.join(d, "plan.md") for d in active_dirs - ) - return ( - f"[plan] Task 완료 감지. " - f"다음 plan.md 의 해당 단계를 [x]로 업데이트하세요: {plan_list}" - ) - return None - - -def main(): - try: - data = json.load(sys.stdin) - except json.JSONDecodeError: - sys.exit(0) - - tool_name = data.get("tool_name", "") - tool_input = data.get("tool_input", {}) - - message = None - if tool_name == "TaskCreate": - message = handle_task_create(tool_input) - elif tool_name == "TaskUpdate": - message = handle_task_update(tool_input) - - if message: - # stderr → Claude에게 피드백 (PostToolUse exit code 2) - print(message, file=sys.stderr) - sys.exit(2) - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/test_block_dangerous.py b/.claude/hooks/test_block_dangerous.py deleted file mode 100644 index 3e12814..0000000 --- a/.claude/hooks/test_block_dangerous.py +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import subprocess -import sys - -import pytest - -SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "block_dangerous.py") - - -def run_hook(command): - data = { - "tool_name": "Bash", - "tool_input": {"command": command}, - } - result = subprocess.run( - [sys.executable, SCRIPT_PATH], - input=json.dumps(data), - capture_output=True, - text=True, - ) - return result - - -# ── 차단 (exit code 2) ── - - -class TestHardBlock: - def test_rm_rf_root(self): - result = run_hook("rm -rf /") - assert result.returncode == 2 - assert "시스템 루트" in result.stderr - - def test_rm_rf_root_wildcard(self): - result = run_hook("rm -rf /*") - assert result.returncode == 2 - - def test_rm_rf_home(self): - result = run_hook("rm -rf ~/") - assert result.returncode == 2 - assert "홈 디렉토리" in result.stderr - - def test_rm_rf_home_no_slash(self): - result = run_hook("rm -rf ~") - assert result.returncode == 2 - - def test_rm_rf_star(self): - result = run_hook("rm -rf *") - assert result.returncode == 2 - assert "현재 디렉토리" in result.stderr - - def test_rm_rf_root_chained(self): - result = run_hook("rm -rf / && echo done") - assert result.returncode == 2 - - -# ── 경고 (exit code 0 + additionalContext) ── - - -class TestSoftWarn: - def test_rm_rf_project_path(self): - result = run_hook("rm -rf /Users/imsubin/project/temp") - assert result.returncode == 0 - output = json.loads(result.stdout) - assert "additionalContext" in output["hookSpecificOutput"] - assert "rm -rf" in output["hookSpecificOutput"]["additionalContext"] - - def test_rm_rf_relative_path(self): - result = run_hook("rm -rf ./build") - assert result.returncode == 0 - output = json.loads(result.stdout) - assert "additionalContext" in output["hookSpecificOutput"] - - def test_rm_rf_parent_dir(self): - result = run_hook("rm -rf ../sibling") - assert result.returncode == 0 - output = json.loads(result.stdout) - assert "상위 디렉토리" in output["hookSpecificOutput"]["additionalContext"] - - def test_git_reset_hard(self): - result = run_hook("git reset --hard HEAD~1") - assert result.returncode == 0 - output = json.loads(result.stdout) - assert "additionalContext" in output["hookSpecificOutput"] - - def test_git_push_force(self): - result = run_hook("git push --force origin feature") - assert result.returncode == 0 - output = json.loads(result.stdout) - assert "force" in output["hookSpecificOutput"]["additionalContext"].lower() - - def test_git_clean_f(self): - result = run_hook("git clean -fd") - assert result.returncode == 0 - assert result.stdout.strip() != "" - - def test_git_checkout_dot(self): - result = run_hook("git checkout .") - assert result.returncode == 0 - assert result.stdout.strip() != "" - - def test_git_stash_drop(self): - result = run_hook("git stash drop") - assert result.returncode == 0 - assert result.stdout.strip() != "" - - def test_drop_table(self): - result = run_hook("psql -c 'DROP TABLE users'") - assert result.returncode == 0 - assert result.stdout.strip() != "" - - -# ── 허용 (exit code 0, 출력 없음) ── - - -class TestAllowed: - def test_ls(self): - result = run_hook("ls -la") - assert result.returncode == 0 - assert result.stdout.strip() == "" - - def test_git_status(self): - result = run_hook("git status") - assert result.returncode == 0 - assert result.stdout.strip() == "" - - def test_git_push_no_force(self): - result = run_hook("git push origin main") - assert result.returncode == 0 - assert result.stdout.strip() == "" - - def test_rm_single_file(self): - result = run_hook("rm file.txt") - assert result.returncode == 0 - assert result.stdout.strip() == "" - - def test_gradle_build(self): - result = run_hook("./gradlew build") - assert result.returncode == 0 - assert result.stdout.strip() == "" - - def test_non_bash_tool(self): - data = { - "tool_name": "Edit", - "tool_input": {"file_path": "/some/file"}, - } - result = subprocess.run( - [sys.executable, SCRIPT_PATH], - input=json.dumps(data), - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert result.stdout.strip() == "" - - def test_invalid_json(self): - result = subprocess.run( - [sys.executable, SCRIPT_PATH], - input="not json", - capture_output=True, - text=True, - ) - assert result.returncode == 0 diff --git a/.claude/hooks/test_layer_doc_reminder.py b/.claude/hooks/test_layer_doc_reminder.py deleted file mode 100644 index 5992b29..0000000 --- a/.claude/hooks/test_layer_doc_reminder.py +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import shutil -import subprocess -import sys -import tempfile - -import pytest - -# 테스트 대상 모듈 import -sys.path.insert(0, os.path.dirname(__file__)) -import layer_doc_reminder as sut - -SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "layer_doc_reminder.py") - - -@pytest.fixture(autouse=True) -def isolated_reminder_dir(tmp_path, monkeypatch): - """각 테스트마다 격리된 임시 디렉토리 사용""" - reminder_dir = str(tmp_path / "reminders") - monkeypatch.setattr(sut, "REMINDER_DIR", reminder_dir) - yield reminder_dir - - -# ── detect_layer ── - - -class TestDetectLayer: - def test_domain(self): - assert sut.detect_layer("/project/task/domain/model/Task.kt") == "domain" - - def test_application(self): - assert sut.detect_layer("/project/task/application/service/TaskService.kt") == "application" - - def test_infrastructure(self): - assert sut.detect_layer("/project/task/infrastructure/persistence/TaskTable.kt") == "infrastructure" - - def test_presentation(self): - assert sut.detect_layer("/project/task/presentation/controller/TaskController.kt") == "presentation" - - def test_unrelated_path(self): - assert sut.detect_layer("/project/build.gradle.kts") is None - - def test_common_config(self): - assert sut.detect_layer("/project/common/config/SecurityConfig.kt") is None - - -# ── is_security_related ── - - -class TestIsSecurityRelated: - def test_security_config(self): - assert sut.is_security_related("/project/common/config/SecurityConfig.kt") is True - - def test_security_case_insensitive(self): - assert sut.is_security_related("/project/common/config/SECURITY_CONFIG.kt") is True - - def test_non_security_config(self): - assert sut.is_security_related("/project/common/config/WebMvcConfig.kt") is False - - def test_security_outside_common_config(self): - assert sut.is_security_related("/project/auth/domain/model/SecurityToken.kt") is False - - -# ── should_remind / mark_reminded ── - - -class TestReminderMarker: - def test_should_remind_when_no_marker(self): - assert sut.should_remind("session-1", "domain") is True - - def test_should_not_remind_after_marked(self): - sut.mark_reminded("session-1", "domain") - assert sut.should_remind("session-1", "domain") is False - - def test_different_session_should_remind(self): - sut.mark_reminded("session-1", "domain") - assert sut.should_remind("session-2", "domain") is True - - def test_different_layer_should_remind(self): - sut.mark_reminded("session-1", "domain") - assert sut.should_remind("session-1", "infrastructure") is True - - -# ── main (subprocess 통합 테스트) ── - - -def run_hook(input_data, reminder_dir): - """hook 스크립트를 subprocess로 실행""" - env = os.environ.copy() - result = subprocess.run( - [sys.executable, SCRIPT_PATH], - input=json.dumps(input_data), - capture_output=True, - text=True, - env=env, - ) - return result - - -class TestMain: - @pytest.fixture(autouse=True) - def setup_reminder_dir(self, isolated_reminder_dir, monkeypatch): - """subprocess에서도 격리된 디렉토리를 쓰도록 환경변수로 전달은 불가하므로, - 실제 REMINDER_DIR을 임시로 교체""" - self.reminder_dir = isolated_reminder_dir - # subprocess는 별도 프로세스이므로 monkeypatch가 안 먹힘. - # 대신 실제 /tmp 경로를 사용하되, 고유 session_id로 격리. - self.session_id = f"test-{os.getpid()}-{id(self)}" - - def _make_input(self, file_path, session_id=None): - return { - "session_id": session_id or self.session_id, - "hook_event_name": "PreToolUse", - "tool_name": "Edit", - "tool_input": {"file_path": file_path}, - } - - def test_first_domain_edit_blocks(self): - result = run_hook( - self._make_input("/project/task/domain/model/Task.kt"), - self.reminder_dir, - ) - assert result.returncode == 2 - assert "domain" in result.stderr - assert "docs/layers/domain.md" in result.stderr - - def test_second_domain_edit_passes(self): - # 첫 번째: 차단 - run_hook( - self._make_input("/project/task/domain/model/Task.kt"), - self.reminder_dir, - ) - # 두 번째: 통과 - result = run_hook( - self._make_input("/project/task/domain/model/Task.kt"), - self.reminder_dir, - ) - assert result.returncode == 0 - - def test_docs_path_always_passes(self): - result = run_hook( - self._make_input("/project/docs/layers/domain.md"), - self.reminder_dir, - ) - assert result.returncode == 0 - - def test_test_file_suffix_always_passes(self): - result = run_hook( - self._make_input("/project/task/domain/model/TaskTest.kt"), - self.reminder_dir, - ) - assert result.returncode == 0 - - def test_src_test_directory_always_passes(self): - result = run_hook( - self._make_input("/project/src/test/kotlin/task/domain/model/Task.kt"), - self.reminder_dir, - ) - assert result.returncode == 0 - - def test_bc_named_test_triggers_reminder(self): - """BC 이름이 test여도 src/test/가 아니므로 차단되어야 함""" - result = run_hook( - self._make_input("/project/test/domain/model/Task.kt"), - self.reminder_dir, - ) - assert result.returncode == 2 - assert "domain" in result.stderr - - def test_unrelated_file_passes(self): - result = run_hook( - self._make_input("/project/build.gradle.kts"), - self.reminder_dir, - ) - assert result.returncode == 0 - - def test_security_config_blocks(self): - result = run_hook( - self._make_input("/project/common/config/SecurityConfig.kt"), - self.reminder_dir, - ) - assert result.returncode == 2 - assert "security" in result.stderr.lower() - assert "spring-security-7.md" in result.stderr - - def test_different_session_blocks_again(self): - # 세션 1: 차단 - run_hook( - self._make_input("/project/task/domain/model/Task.kt", session_id="session-A"), - self.reminder_dir, - ) - # 세션 2: 다시 차단 - result = run_hook( - self._make_input("/project/task/domain/model/Task.kt", session_id="session-B"), - self.reminder_dir, - ) - assert result.returncode == 2 - - def test_empty_file_path_passes(self): - result = run_hook( - {"session_id": self.session_id, "tool_input": {"file_path": ""}}, - self.reminder_dir, - ) - assert result.returncode == 0 - - def test_invalid_json_passes(self): - """잘못된 JSON 입력 시 허용 (안전 기본값)""" - result = subprocess.run( - [sys.executable, SCRIPT_PATH], - input="not json", - capture_output=True, - text=True, - ) - assert result.returncode == 0 - - def teardown_method(self): - """subprocess 테스트에서 생성된 마커 정리""" - import glob - for f in glob.glob(f"/tmp/claude_layer_reminders/{self.session_id}_*"): - os.remove(f) - for f in glob.glob("/tmp/claude_layer_reminders/session-A_*"): - os.remove(f) - for f in glob.glob("/tmp/claude_layer_reminders/session-B_*"): - os.remove(f) diff --git a/.claude/hooks/test_plan_completion_guard.py b/.claude/hooks/test_plan_completion_guard.py deleted file mode 100644 index 7ca7d55..0000000 --- a/.claude/hooks/test_plan_completion_guard.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -"""plan_completion_guard.py 단위 테스트.""" -import json -import os -import subprocess -import sys -import tempfile - -SCRIPT = os.path.join(os.path.dirname(__file__), "plan_completion_guard.py") - - -def run_hook(tool_name, tool_input, plan_base=None): - """훅 스크립트를 실행하고 (exit_code, stderr)를 반환.""" - data = json.dumps({"tool_name": tool_name, "tool_input": tool_input}) - env = os.environ.copy() - if plan_base is not None: - env["CLAUDE_PROJECT_DIR"] = plan_base - result = subprocess.run( - [sys.executable, SCRIPT], - input=data, - capture_output=True, - text=True, - env=env, - ) - return result.returncode, result.stderr - - -def create_plan_dir(base, name, complete=True, extra_files=None): - """테스트용 plan 디렉토리 생성. 정책상 plan.md + checklist.md 2종이 필수.""" - plan_dir = os.path.join(base, "docs", "plan", name) - os.makedirs(plan_dir, exist_ok=True) - check = "[x]" if complete else "[ ]" - with open(os.path.join(plan_dir, "plan.md"), "w") as f: - f.write(f"# Test\n- {check} step 1\n") - with open(os.path.join(plan_dir, "checklist.md"), "w") as f: - f.write("# Checklist\n") - for extra in extra_files or []: - with open(os.path.join(plan_dir, extra), "w") as f: - f.write(f"# {extra}\n") - - -def test_non_bash_allowed(): - code, _ = run_hook("Edit", {"file_path": "src/main/Test.kt"}) - assert code == 0, f"Non-Bash should be allowed, got {code}" - - -def test_non_pr_push_allowed(): - code, _ = run_hook("Bash", {"command": "git status"}) - assert code == 0, f"Non PR/push should be allowed, got {code}" - - -def test_pr_create_blocked_with_incomplete_plan(): - with tempfile.TemporaryDirectory() as tmp: - create_plan_dir(tmp, "test-task", complete=False) - code, stderr = run_hook( - "Bash", {"command": 'gh pr create --title "test"'}, plan_base=tmp - ) - assert code == 2, f"Should block, got {code}" - assert "plan-guard" in stderr - - -def test_git_push_blocked_with_incomplete_plan(): - with tempfile.TemporaryDirectory() as tmp: - create_plan_dir(tmp, "test-task", complete=False) - code, stderr = run_hook( - "Bash", {"command": "git push -u origin main"}, plan_base=tmp - ) - assert code == 2, f"Should block, got {code}" - assert "plan-guard" in stderr - - -def test_pr_create_allowed_with_complete_plan(): - with tempfile.TemporaryDirectory() as tmp: - create_plan_dir(tmp, "test-task", complete=True) - code, _ = run_hook( - "Bash", {"command": 'gh pr create --title "test"'}, plan_base=tmp - ) - assert code == 0, f"Should allow, got {code}" - - -def test_pr_create_allowed_with_no_plans(): - with tempfile.TemporaryDirectory() as tmp: - os.makedirs(os.path.join(tmp, "docs", "plan"), exist_ok=True) - code, _ = run_hook( - "Bash", {"command": 'gh pr create --title "test"'}, plan_base=tmp - ) - assert code == 0, f"Should allow with no plans, got {code}" - - -def test_mixed_plans_blocked(): - with tempfile.TemporaryDirectory() as tmp: - create_plan_dir(tmp, "done-task", complete=True) - create_plan_dir(tmp, "wip-task", complete=False) - code, stderr = run_hook( - "Bash", {"command": 'gh pr create --title "test"'}, plan_base=tmp - ) - assert code == 2, f"Should block with any incomplete, got {code}" - assert "wip-task" in stderr - - -def test_two_doc_incomplete_plan_blocked(): - """정책 정합성 회귀: plan.md + checklist.md 2종만 있는 미완료 plan은 차단되어야 함. - - 이전 가드는 context.md까지 3종을 요구해, 정책에 맞춰 2종으로 작성된 미완료 plan이 - 거짓통과(exit=0) 되는 결함이 있었다. (#44) - """ - with tempfile.TemporaryDirectory() as tmp: - create_plan_dir(tmp, "two-doc-wip", complete=False) - code, stderr = run_hook( - "Bash", {"command": 'gh pr create --title "test"'}, plan_base=tmp - ) - assert code == 2, f"Should block 2-doc incomplete plan, got {code}" - assert "two-doc-wip" in stderr - - -def test_two_doc_with_extra_files_still_blocked(): - """가드는 필수 2종만 보고, 추가 파일이 있어도 미완료면 차단되어야 함.""" - with tempfile.TemporaryDirectory() as tmp: - create_plan_dir( - tmp, "extras-wip", complete=False, extra_files=["context.md", "notes.md"] - ) - code, _ = run_hook( - "Bash", {"command": "git push origin main"}, plan_base=tmp - ) - assert code == 2, f"Should block regardless of extra files, got {code}" - - -def test_plan_without_checklist_skipped(): - """필수 2종이 모두 갖춰지지 않은 디렉토리는 가드 대상이 아님.""" - with tempfile.TemporaryDirectory() as tmp: - plan_dir = os.path.join(tmp, "docs", "plan", "no-checklist") - os.makedirs(plan_dir, exist_ok=True) - with open(os.path.join(plan_dir, "plan.md"), "w") as f: - f.write("# Test\n- [ ] step 1\n") - code, _ = run_hook( - "Bash", {"command": 'gh pr create --title "test"'}, plan_base=tmp - ) - assert code == 0, f"Incomplete-but-not-required dir should be skipped, got {code}" - - -if __name__ == "__main__": - tests = [ - test_non_bash_allowed, - test_non_pr_push_allowed, - test_pr_create_blocked_with_incomplete_plan, - test_git_push_blocked_with_incomplete_plan, - test_pr_create_allowed_with_complete_plan, - test_pr_create_allowed_with_no_plans, - test_mixed_plans_blocked, - test_two_doc_incomplete_plan_blocked, - test_two_doc_with_extra_files_still_blocked, - test_plan_without_checklist_skipped, - ] - failed = 0 - for test in tests: - try: - test() - print(f" PASS: {test.__name__}") - except AssertionError as e: - print(f" FAIL: {test.__name__}: {e}") - failed += 1 - print(f"\n{len(tests) - failed}/{len(tests)} passed") - sys.exit(1 if failed else 0) diff --git a/.claude/hooks/test_plan_update_reminder.py b/.claude/hooks/test_plan_update_reminder.py deleted file mode 100644 index 88dad9e..0000000 --- a/.claude/hooks/test_plan_update_reminder.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import subprocess -import sys - -import pytest - -sys.path.insert(0, os.path.dirname(__file__)) -import plan_update_reminder as sut - -SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "plan_update_reminder.py") - - -@pytest.fixture() -def plan_base(tmp_path, monkeypatch): - """격리된 임시 PLAN_BASE 디렉토리.""" - base = str(tmp_path / "docs" / "plan") - monkeypatch.setattr(sut, "PLAN_BASE", base) - return base - - -def _make_plan_dir(plan_base, name, unchecked=True): - """plan 디렉토리와 plan.md 생성. unchecked=True이면 미완료 항목 포함.""" - d = os.path.join(plan_base, name) - os.makedirs(d, exist_ok=True) - content = "- [ ] 1단계\n- [ ] 2단계" if unchecked else "- [x] 1단계\n- [x] 2단계" - with open(os.path.join(d, "plan.md"), "w") as f: - f.write(content) - return d - - -# ── find_active_plan_dirs ── - - -class TestFindActivePlanDirs: - def test_empty_when_no_plan_base(self, monkeypatch): - monkeypatch.setattr(sut, "PLAN_BASE", "") - assert sut.find_active_plan_dirs() == [] - - def test_empty_when_dir_not_exists(self, monkeypatch, tmp_path): - monkeypatch.setattr(sut, "PLAN_BASE", str(tmp_path / "nonexistent")) - assert sut.find_active_plan_dirs() == [] - - def test_finds_dir_with_unchecked_items(self, plan_base): - _make_plan_dir(plan_base, "feature-a", unchecked=True) - result = sut.find_active_plan_dirs() - assert len(result) == 1 - assert "feature-a" in result[0] - - def test_ignores_fully_checked_plan(self, plan_base): - _make_plan_dir(plan_base, "feature-b", unchecked=False) - assert sut.find_active_plan_dirs() == [] - - def test_mixed_plans(self, plan_base): - _make_plan_dir(plan_base, "active", unchecked=True) - _make_plan_dir(plan_base, "done", unchecked=False) - result = sut.find_active_plan_dirs() - assert len(result) == 1 - assert "active" in result[0] - - -# ── handle_task_create ── - - -class TestHandleTaskCreate: - def test_reminds_when_no_active_plan(self, plan_base): - msg = sut.handle_task_create({"subject": "새 기능 구현"}) - assert msg is not None - assert "새 기능 구현" in msg - assert "plan.md" in msg - - def test_no_reminder_when_active_plan_exists(self, plan_base): - _make_plan_dir(plan_base, "existing", unchecked=True) - msg = sut.handle_task_create({"subject": "추가 작업"}) - assert msg is None - - -# ── handle_task_update ── - - -class TestHandleTaskUpdate: - def test_reminds_on_completed_with_active_plan(self, plan_base): - _make_plan_dir(plan_base, "feature-x", unchecked=True) - msg = sut.handle_task_update({"taskId": "1", "status": "completed"}) - assert msg is not None - assert "plan.md" in msg - assert "[x]" in msg - - def test_no_reminder_on_non_completed_status(self, plan_base): - _make_plan_dir(plan_base, "feature-x", unchecked=True) - msg = sut.handle_task_update({"taskId": "1", "status": "in_progress"}) - assert msg is None - - def test_no_reminder_when_no_active_plan(self, plan_base): - msg = sut.handle_task_update({"taskId": "1", "status": "completed"}) - assert msg is None - - def test_no_reminder_when_all_plans_done(self, plan_base): - _make_plan_dir(plan_base, "done", unchecked=False) - msg = sut.handle_task_update({"taskId": "1", "status": "completed"}) - assert msg is None - - -# ── main (subprocess 통합 테스트) ── - - -def run_hook(input_data, env_override=None): - env = os.environ.copy() - if env_override: - env.update(env_override) - result = subprocess.run( - [sys.executable, SCRIPT_PATH], - input=json.dumps(input_data), - capture_output=True, - text=True, - env=env, - ) - return result - - -class TestMain: - def test_task_create_blocks_with_stderr(self, tmp_path): - env = {"CLAUDE_PROJECT_DIR": str(tmp_path)} - data = { - "tool_name": "TaskCreate", - "tool_input": {"subject": "인증 구현"}, - } - result = run_hook(data, env) - assert result.returncode == 2 - assert "인증 구현" in result.stderr - assert "plan.md" in result.stderr - - def test_task_update_completed_with_active_plan(self, tmp_path): - plan_dir = tmp_path / "docs" / "plan" / "feature" - plan_dir.mkdir(parents=True) - (plan_dir / "plan.md").write_text("- [ ] 1단계\n- [x] 2단계") - - data = { - "tool_name": "TaskUpdate", - "tool_input": {"taskId": "1", "status": "completed"}, - } - result = run_hook(data, {"CLAUDE_PROJECT_DIR": str(tmp_path)}) - assert result.returncode == 2 - assert "plan.md" in result.stderr - - def test_task_update_in_progress_no_output(self, tmp_path): - plan_dir = tmp_path / "docs" / "plan" / "feature" - plan_dir.mkdir(parents=True) - (plan_dir / "plan.md").write_text("- [ ] 1단계") - - data = { - "tool_name": "TaskUpdate", - "tool_input": {"taskId": "1", "status": "in_progress"}, - } - result = run_hook(data, {"CLAUDE_PROJECT_DIR": str(tmp_path)}) - assert result.returncode == 0 - assert result.stdout.strip() == "" - - def test_invalid_json_passes(self): - result = subprocess.run( - [sys.executable, SCRIPT_PATH], - input="not json", - capture_output=True, - text=True, - ) - assert result.returncode == 0 - - def test_unknown_tool_no_output(self, tmp_path): - data = { - "tool_name": "Edit", - "tool_input": {"file_path": "/some/file.kt"}, - } - result = run_hook(data, {"CLAUDE_PROJECT_DIR": str(tmp_path)}) - assert result.returncode == 0 - assert result.stdout.strip() == "" diff --git a/.claude/hooks/test_work_plan_enforcer.py b/.claude/hooks/test_work_plan_enforcer.py deleted file mode 100644 index c7b13d9..0000000 --- a/.claude/hooks/test_work_plan_enforcer.py +++ /dev/null @@ -1,354 +0,0 @@ -#!/usr/bin/env python3 -""" -work_plan_enforcer.py 훅 테스트 -""" -import json -import os -import shutil -import subprocess -import sys -import tempfile - -SCRIPT = os.path.join(os.path.dirname(__file__), "work_plan_enforcer.py") - -passed = 0 -failed = 0 - - -def run_hook(tool_name, tool_input, project_dir=None): - """훅을 실행하고 (exit_code, stderr) 반환.""" - data = {"tool_name": tool_name, "tool_input": tool_input, "session_id": "test-session"} - env = os.environ.copy() - if project_dir: - env["CLAUDE_PROJECT_DIR"] = project_dir - else: - env.pop("CLAUDE_PROJECT_DIR", None) - - result = subprocess.run( - [sys.executable, SCRIPT], - input=json.dumps(data), - capture_output=True, - text=True, - env=env, - ) - return result.returncode, result.stderr - - -def test(name, exit_code, expected_code, stderr=""): - global passed, failed - if exit_code == expected_code: - print(f" PASS: {name}") - passed += 1 - else: - print(f" FAIL: {name} (expected {expected_code}, got {exit_code})") - if stderr: - print(f" stderr: {stderr}") - failed += 1 - - -def create_active_plan(plan_dir): - """2종 문서를 모두 생성하여 활성 계획을 만든다.""" - os.makedirs(plan_dir, exist_ok=True) - with open(os.path.join(plan_dir, "plan.md"), "w") as f: - f.write("# Test Plan\n\n- [ ] 1단계: 구현\n- [ ] 2단계: 검증\n") - with open(os.path.join(plan_dir, "checklist.md"), "w") as f: - f.write("# Test Checklist\n\n- [ ] 테스트 통과\n") - - -def main(): - global passed, failed - - tmp_dir = tempfile.mkdtemp(prefix="hook_test_") - plan_dir = os.path.join(tmp_dir, "docs", "plan", "test-task") - os.makedirs(os.path.join(tmp_dir, "docs", "plan"), exist_ok=True) - - try: - # ── Edit/Write: 계획 없는 상태 ────────────────────────── - print("\n[Edit/Write - 계획 없는 상태]") - - code, err = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("Edit src/ 소스 코드 → 차단", code, 2, err) - - code, err = run_hook( - "Write", - {"file_path": f"{tmp_dir}/src/main/kotlin/NewFile.kt"}, - project_dir=tmp_dir, - ) - test("Write src/ 소스 코드 → 차단", code, 2, err) - - # ── Edit/Write: 예외 경로 ────────────────────────────── - print("\n[Edit/Write - 예외 경로 (계획 없어도 허용)]") - - code, _ = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/docs/architecture.md"}, - project_dir=tmp_dir, - ) - test("docs/ 문서 수정 → 허용", code, 0) - - code, _ = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/.claude/hooks/test.py"}, - project_dir=tmp_dir, - ) - test(".claude/ 파일 수정 → 허용", code, 0) - - code, _ = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/.opencode/plugins/test.js"}, - project_dir=tmp_dir, - ) - test(".opencode/ 파일 수정 → 허용", code, 0) - - code, _ = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/build.gradle.kts"}, - project_dir=tmp_dir, - ) - test("build.gradle.kts → 허용", code, 0) - - code, _ = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/src/main/resources/application.yml"}, - project_dir=tmp_dir, - ) - test("application.yml → 허용", code, 0) - - code, _ = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/CLAUDE.md"}, - project_dir=tmp_dir, - ) - test("CLAUDE.md → 허용", code, 0) - - code, _ = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/README.md"}, - project_dir=tmp_dir, - ) - test("README.md → 허용", code, 0) - - code, _ = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/settings.gradle.kts"}, - project_dir=tmp_dir, - ) - test("settings.gradle.kts → 허용", code, 0) - - # ── 경로 정규화 ────────────────────────────────────── - print("\n[경로 정규화 (백슬래시)]") - - code, err = run_hook( - "Edit", - {"file_path": f"{tmp_dir}\\src\\main\\kotlin\\Task.kt"}, - project_dir=tmp_dir, - ) - test("백슬래시 src 경로 → 차단", code, 2, err) - - code, _ = run_hook( - "Edit", - {"file_path": f"{tmp_dir}\\docs\\architecture.md"}, - project_dir=tmp_dir, - ) - test("백슬래시 docs 경로 → 허용", code, 0) - - # ── 활성 계획 존재 (2종 문서 완비) ──────────────────────── - print("\n[활성 계획 존재 - 2종 문서 완비]") - - create_active_plan(plan_dir) - - code, _ = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("2종 문서 완비 + 미완료 항목 → src/ 수정 허용", code, 0) - - # ── 2종 문서 불완전 ────────────────────────────────── - print("\n[2종 문서 불완전 - 활성 계획 아님]") - - # checklist.md 삭제 - os.remove(os.path.join(plan_dir, "checklist.md")) - code, err = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("checklist.md 없으면 → 차단", code, 2, err) - - # plan.md만 있는 경우 - shutil.rmtree(plan_dir) - os.makedirs(plan_dir) - with open(os.path.join(plan_dir, "plan.md"), "w") as f: - f.write("# Plan\n\n- [ ] TODO\n") - code, err = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("plan.md만 있으면 → 차단", code, 2, err) - - # ── 모든 계획 완료 ────────────────────────────────── - print("\n[모든 계획 완료]") - - shutil.rmtree(plan_dir) - create_active_plan(plan_dir) - with open(os.path.join(plan_dir, "plan.md"), "w") as f: - f.write("# Plan\n\n- [x] 1단계: 완료\n- [x] 2단계: 완료\n") - - code, err = run_hook( - "Edit", - {"file_path": f"{tmp_dir}/src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("모든 항목 완료 → src/ 수정 차단", code, 2, err) - - # ── Bash 우회 차단 ────────────────────────────────── - print("\n[Bash 우회 차단 - 계획 없는 상태]") - - # 2종 완비 상태 제거 - shutil.rmtree(plan_dir, ignore_errors=True) - - code, err = run_hook( - "Bash", - {"command": "echo hello > src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("echo > src/ → 차단", code, 2, err) - - code, err = run_hook( - "Bash", - {"command": "cat data.txt >> src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("cat >> src/ → 차단", code, 2, err) - - code, err = run_hook( - "Bash", - {"command": "sed -i 's/old/new/g' src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("sed -i src/ → 차단", code, 2, err) - - code, err = run_hook( - "Bash", - {"command": "tee src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("tee src/ → 차단", code, 2, err) - - code, err = run_hook( - "Bash", - {"command": "cp template.kt src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("cp → src/ → 차단", code, 2, err) - - code, err = run_hook( - "Bash", - {"command": "mv old.kt src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("mv → src/ → 차단", code, 2, err) - - code, err = run_hook( - "Bash", - {"command": "rm src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("rm src/ → 차단", code, 2, err) - - # ── Bash 허용 케이스 ───────────────────────────────── - print("\n[Bash 허용 케이스]") - - code, _ = run_hook( - "Bash", - {"command": "cat src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("cat src/ (읽기 전용) → 허용", code, 0) - - code, _ = run_hook( - "Bash", - {"command": "grep -r 'class' src/"}, - project_dir=tmp_dir, - ) - test("grep src/ (읽기 전용) → 허용", code, 0) - - code, _ = run_hook( - "Bash", - {"command": "ls src/main/kotlin/"}, - project_dir=tmp_dir, - ) - test("ls src/ (읽기 전용) → 허용", code, 0) - - code, _ = run_hook( - "Bash", - {"command": "./gradlew build"}, - project_dir=tmp_dir, - ) - test("gradlew build (src 없음) → 허용", code, 0) - - code, _ = run_hook( - "Bash", - {"command": "echo hello > docs/output.md"}, - project_dir=tmp_dir, - ) - test("echo > docs/ → 허용", code, 0) - - # ── Bash + 활성 계획 ───────────────────────────────── - print("\n[Bash + 활성 계획 존재]") - - create_active_plan(plan_dir) - - code, _ = run_hook( - "Bash", - {"command": "echo hello > src/main/kotlin/Task.kt"}, - project_dir=tmp_dir, - ) - test("활성 계획 있으면 Bash src/ 쓰기 → 허용", code, 0) - - shutil.rmtree(plan_dir, ignore_errors=True) - - # ── Bash 예외 경로 ─────────────────────────────────── - print("\n[Bash 예외 경로]") - - code, _ = run_hook( - "Bash", - {"command": "echo hello > src/main/resources/application.yml"}, - project_dir=tmp_dir, - ) - test("Bash yml 설정 파일 → 허용", code, 0) - - # ── 엣지 케이스 ────────────────────────────────────── - print("\n[엣지 케이스]") - - code, _ = run_hook("Edit", {"file_path": ""}, project_dir=tmp_dir) - test("빈 file_path → 허용", code, 0) - - code, _ = run_hook("Edit", {}, project_dir=tmp_dir) - test("file_path 없음 → 허용", code, 0) - - code, _ = run_hook("Bash", {"command": ""}, project_dir=tmp_dir) - test("빈 command → 허용", code, 0) - - code, _ = run_hook("Read", {"file_path": "src/main/kotlin/Task.kt"}, project_dir=tmp_dir) - test("Read 도구 (비수정) → 허용", code, 0) - - finally: - shutil.rmtree(tmp_dir, ignore_errors=True) - - # ── 결과 요약 ────────────────────────────────────────── - print(f"\n{'='*50}") - print(f"결과: {passed} passed, {failed} failed") - if failed: - sys.exit(1) - print("All tests passed!") - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/work_plan_enforcer.py b/.claude/hooks/work_plan_enforcer.py deleted file mode 100755 index c577068..0000000 --- a/.claude/hooks/work_plan_enforcer.py +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env python3 -""" -Claude Code 훅: 작업 계획 강제 -소스 코드(src/) 수정 시 docs/plan/ 하위에 활성 작업 계획이 존재하는지 검사합니다. -활성 계획이 없으면 차단(exit code 2)하여 계획 문서를 먼저 생성하도록 유도합니다. - -활성 계획 조건: plan.md, checklist.md 2종이 모두 존재하고, -plan.md에 미완료 항목(- [ ])이 있어야 합니다. - -예외 (work-planning-rules.md 기준): -- 문서 작성·수정 (docs/, README, CLAUDE.md 등) -- 설정 파일 변경 (build.gradle.kts, application.yml 등) -- .claude/ 디렉토리 파일 -- 빌드·CI/CD 설정 - -종료 코드: - 0 = 허용 - 2 = 차단 (작업 계획을 먼저 생성하라는 메시지) -""" -import json -import os -import re -import sys - -PROJECT_DIR = os.environ.get("CLAUDE_PROJECT_DIR", "") -PLAN_BASE = os.path.join(PROJECT_DIR, "docs", "plan") if PROJECT_DIR else "" - -REQUIRED_PLAN_FILES = ["plan.md", "checklist.md"] - -# 예외 경로 패턴: 이 문자열이 파일 경로에 포함되면 검사하지 않음 -EXEMPT_PATTERNS = [ - "/docs/", - "/.claude/", - "/.opencode/", - "README", - "CLAUDE.md", -] - -# 예외 파일 확장자 (설정/빌드 파일) -EXEMPT_EXTENSIONS = [ - ".gradle.kts", - ".gradle", - ".yml", - ".yaml", - ".properties", - ".toml", - ".xml", -] - -# Bash 쓰기 명령 패턴 -BASH_WRITE_PATTERNS = [ - re.compile(r"\b(touch|mkdir|cp|mv|rm|install|truncate|dd)\b", re.I), - re.compile(r"\b(sed|perl)\b[\s\S]*\s-i\b", re.I), - re.compile(r"\btee\b", re.I), - re.compile(r"\b(cat|echo|printf)\b[\s\S]*>{1,2}", re.I), - re.compile(r">{1,2}\s*[\"']?[^\s\"']*src/", re.I), - re.compile( - r"\bpython(?:3)?\b[\s\S]*open\(\s*[\"'][^\"']*src/[^\"']*[\"']\s*,\s*[\"'][wa]", - re.I, - ), -] - -SRC_PATH_RE = re.compile(r"(^|/)src/") - - -def normalize_path(file_path): - """경로 구분자를 통일.""" - return file_path.replace("\\", "/") - - -def is_exempt(file_path): - """작업 계획 프로세스 예외 대상인지 확인.""" - normalized = normalize_path(file_path) - - for pattern in EXEMPT_PATTERNS: - if pattern in normalized: - return True - - if normalized.startswith("docs/"): - return True - if normalized.startswith(".claude/"): - return True - if normalized.startswith(".opencode/"): - return True - - for ext in EXEMPT_EXTENSIONS: - if normalized.endswith(ext): - return True - - return False - - -def is_src_path(file_path): - """src/ 하위 경로인지 정규식으로 확인.""" - return bool(SRC_PATH_RE.search(normalize_path(file_path))) - - -def has_active_plan(): - """docs/plan/ 하위에 활성 작업 계획이 존재하는지 확인. - - 활성 계획 조건: plan.md, checklist.md 2종 모두 존재 + plan.md에 미완료 항목. - """ - if not PLAN_BASE or not os.path.isdir(PLAN_BASE): - return False - - try: - entries = os.listdir(PLAN_BASE) - except OSError: - return False - - for entry in entries: - plan_dir = os.path.join(PLAN_BASE, entry) - if not os.path.isdir(plan_dir): - continue - - # 2종 문서 존재 확인 - if not all( - os.path.isfile(os.path.join(plan_dir, f)) for f in REQUIRED_PLAN_FILES - ): - continue - - plan_md = os.path.join(plan_dir, "plan.md") - with open(plan_md) as f: - content = f.read() - if "- [ ]" in content: - return True - - return False - - -def extract_src_path_candidates(command): - """Bash 명령어에서 src/ 경로 후보를 추출.""" - paths = [] - for match in re.finditer(r"(?:^|[\s\"'])([^\s\"'`]*src/[^\s\"'`]+)", command): - path = re.sub(r"[;|,&]+$", "", match.group(1)) - paths.append(path) - return paths - - -def is_bash_source_mutation(command): - """Bash 명령어가 src/ 파일을 변경하는 쓰기 명령인지 감지.""" - if not re.search(r"(^|\W)src/", command): - return False - return any(pattern.search(command) for pattern in BASH_WRITE_PATTERNS) - - -def should_block_file_path(file_path): - """파일 경로가 차단 대상인지 확인.""" - normalized = normalize_path(file_path) - if is_exempt(normalized): - return False - return is_src_path(normalized) - - -def block_exit(): - """차단 메시지를 출력하고 종료.""" - print( - "[work-plan] 소스 코드 수정이 차단되었습니다. " - "docs/plan/{작업명}/ 에 plan.md, checklist.md 를 먼저 생성하세요. " - "(docs/work-planning-rules.md 참고)", - file=sys.stderr, - ) - sys.exit(2) - - -def main(): - try: - data = json.load(sys.stdin) - except json.JSONDecodeError: - sys.exit(0) - - tool_name = data.get("tool_name", "") - tool_input = data.get("tool_input", {}) - - # Bash 도구: 쓰기 명령 감지 - if tool_name == "Bash": - command = tool_input.get("command", "") - if not is_bash_source_mutation(command): - sys.exit(0) - - src_paths = extract_src_path_candidates(command) - has_non_exempt = any(should_block_file_path(p) for p in src_paths) - if not has_non_exempt: - sys.exit(0) - - if has_active_plan(): - sys.exit(0) - - block_exit() - - # Edit/Write 도구: 파일 경로 검사 - if tool_name not in ("Edit", "Write"): - sys.exit(0) - - file_path = tool_input.get("file_path", "") - if not file_path: - sys.exit(0) - - if not should_block_file_path(file_path): - sys.exit(0) - - if has_active_plan(): - sys.exit(0) - - block_exit() - - -if __name__ == "__main__": - main() diff --git a/.claude/settings.json b/.claude/settings.json index 44a424a..db0781d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,15 +9,15 @@ "hooks": [ { "type": "command", - "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block_dangerous.py\"" + "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" block-dangerous" }, { "type": "command", - "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/work_plan_enforcer.py\"" + "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" work-plan-enforcer" }, { "type": "command", - "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/plan_completion_guard.py\"" + "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" plan-completion-guard" } ] }, @@ -26,11 +26,11 @@ "hooks": [ { "type": "command", - "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/layer_doc_reminder.py\"" + "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" layer-doc-reminder" }, { "type": "command", - "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/work_plan_enforcer.py\"" + "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" work-plan-enforcer" } ] } @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/plan_update_reminder.py\"" + "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" plan-update-reminder" } ] }, diff --git a/.opencode/lib/work-plan-utils.js b/.opencode/lib/work-plan-utils.js deleted file mode 100644 index 16f44c0..0000000 --- a/.opencode/lib/work-plan-utils.js +++ /dev/null @@ -1,64 +0,0 @@ -import { existsSync, readFileSync, readdirSync, statSync } from "fs" -import { join, resolve } from "path" - -const REQUIRED_PLAN_FILES = ["plan.md", "checklist.md"] - -function hasUncheckedItems(content) { - return content.includes("- [ ]") -} - -function latestPlanTimestamp(planDir) { - let latest = 0 - for (const file of REQUIRED_PLAN_FILES) { - const filePath = join(planDir, file) - if (!existsSync(filePath)) continue - const mtime = statSync(filePath).mtimeMs - if (mtime > latest) latest = mtime - } - return latest -} - -function hasRequiredPlanFiles(planDir) { - return REQUIRED_PLAN_FILES.every((file) => existsSync(join(planDir, file))) -} - -export function findActivePlans(directory) { - const planBase = resolve(directory, "docs", "plan") - if (!existsSync(planBase)) return [] - - let dirs - try { - dirs = readdirSync(planBase, { withFileTypes: true }).filter((d) => d.isDirectory()) - } catch { - return [] - } - - const activePlans = [] - - for (const dir of dirs) { - const planDir = join(planBase, dir.name) - if (!hasRequiredPlanFiles(planDir)) continue - - const planMdPath = join(planDir, "plan.md") - const content = readFileSync(planMdPath, "utf-8") - if (!hasUncheckedItems(content)) continue - - activePlans.push({ - name: dir.name, - planMdPath, - updatedAtMs: latestPlanTimestamp(planDir), - }) - } - - activePlans.sort((a, b) => b.updatedAtMs - a.updatedAtMs) - return activePlans -} - -export function findPrimaryActivePlan(directory) { - const activePlans = findActivePlans(directory) - return activePlans.length > 0 ? activePlans[0] : null -} - -export function hasActivePlan(directory) { - return findPrimaryActivePlan(directory) !== null -} diff --git a/.opencode/plugins/__tests__/loop-hooks.test.js b/.opencode/plugins/__tests__/loop-hooks.test.js new file mode 100644 index 0000000..adee983 --- /dev/null +++ b/.opencode/plugins/__tests__/loop-hooks.test.js @@ -0,0 +1,189 @@ +import { describe, expect, it } from "bun:test" +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { LoopHooks } from "../loop-hooks.js" + +function makeDir() { + return mkdtempSync(join(tmpdir(), "loop-hooks-")) +} + +function makeActivePlan(dir, name = "active") { + const planDir = join(dir, "docs", "plan", name) + mkdirSync(planDir, { recursive: true }) + const planMdPath = join(planDir, "plan.md") + writeFileSync(planMdPath, "- [ ] step") + writeFileSync(join(planDir, "checklist.md"), "# c") + return planMdPath +} + +function todoUpdatedEvent(todo, sessionID = "session-1") { + return { + type: "todo.updated", + properties: { + sessionID, + todos: [todo], + }, + } +} + +describe("LoopHooks adapter", () => { + it("blocks rm -rf / via block-dangerous", async () => { + const dir = makeDir() + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin["tool.execute.before"]( + { tool: "bash" }, + { args: { command: "rm -rf /" } }, + ), + ).rejects.toThrow(/Safety Hook/) + }) + + it("blocks src write without active plan", async () => { + const dir = makeDir() + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin["tool.execute.before"]( + { tool: "write" }, + { args: { filePath: "src/main/kotlin/Task.kt" } }, + ), + ).rejects.toThrow(/work-plan/) + }) + + it("allows src write with active plan", async () => { + const dir = makeDir() + makeActivePlan(dir) + const plugin = await LoopHooks({ directory: dir }) + // layer-doc-reminder will fire on first domain edit, so use a non-layer src path + await expect( + plugin["tool.execute.before"]( + { tool: "write" }, + { args: { filePath: "src/main/kotlin/util/X.kt" } }, + ), + ).resolves.toBeUndefined() + }) + + it("blocks first domain layer edit via layer-doc-reminder", async () => { + const dir = makeDir() + makeActivePlan(dir) + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin["tool.execute.before"]( + { tool: "edit" }, + { args: { filePath: "src/main/kotlin/task/domain/model/Task.kt" } }, + ), + ).rejects.toThrow(/domain/) + }) + + it("blocks gh pr create with incomplete plan via plan-completion-guard", async () => { + const dir = makeDir() + makeActivePlan(dir, "wip") + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin["tool.execute.before"]( + { tool: "bash" }, + { args: { command: 'gh pr create --title "x"' } }, + ), + ).rejects.toThrow(/plan-guard/) + }) + + it("blocks apply_patch on src without plan", async () => { + const dir = makeDir() + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin["tool.execute.before"]( + { tool: "apply_patch" }, + { args: { patchText: "*** Update File: src/main/kotlin/Task.kt\n" } }, + ), + ).rejects.toThrow(/work-plan/) + }) + + it("plan-update-reminder fires on TaskCreate without plan", async () => { + const dir = makeDir() + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin["tool.execute.after"]( + { tool: "task_create", args: { subject: "신규 기능" } }, + { title: "", output: "", metadata: {} }, + ), + ).rejects.toThrow(/plan/) + }) + + it("plan-update-reminder reads TaskUpdate status from after-hook input args", async () => { + const dir = makeDir() + const planMdPath = makeActivePlan(dir) + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin["tool.execute.after"]( + { tool: "task_update", args: { status: "completed" } }, + { title: "", output: "", metadata: {} }, + ), + ).rejects.toThrow(planMdPath) + }) + + it("todo.updated pending event reminds when no active plan exists", async () => { + const dir = makeDir() + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin.event({ + event: todoUpdatedEvent({ content: "신규 기능", status: "pending", priority: "medium" }), + }), + ).rejects.toThrow(/TaskCreate/) + }) + + it("todo.updated pending event is silent when active plan exists", async () => { + const dir = makeDir() + makeActivePlan(dir) + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin.event({ + event: todoUpdatedEvent({ content: "추가 작업", status: "pending", priority: "medium" }), + }), + ).resolves.toBeUndefined() + }) + + it("todo.updated completed event reminds with active plan path", async () => { + const dir = makeDir() + const planMdPath = makeActivePlan(dir) + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin.event({ + event: todoUpdatedEvent({ content: "1단계", status: "completed", priority: "medium" }), + }), + ).rejects.toThrow(planMdPath) + }) + + it("todo.updated in_progress event does not masquerade as TaskCreate", async () => { + const dir = makeDir() + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin.event({ + event: todoUpdatedEvent({ content: "신규 기능", status: "in_progress", priority: "medium" }), + }), + ).resolves.toBeUndefined() + }) + + it("todo.updated pending event skips exempt task", async () => { + const dir = makeDir() + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin.event({ + event: todoUpdatedEvent({ content: "CLAUDE.md 문서 수정", status: "pending", priority: "medium" }), + }), + ).resolves.toBeUndefined() + }) + + it("ignores non-todo events", async () => { + const dir = makeDir() + const plugin = await LoopHooks({ directory: dir }) + await expect( + plugin.event({ + event: { + type: "session.updated", + properties: { status: "pending", content: "신규 기능" }, + }, + }), + ).resolves.toBeUndefined() + }) +}) diff --git a/.opencode/plugins/__tests__/plan-update-reminder.test.js b/.opencode/plugins/__tests__/plan-update-reminder.test.js deleted file mode 100644 index 72d29f3..0000000 --- a/.opencode/plugins/__tests__/plan-update-reminder.test.js +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { mkdtempSync, mkdirSync, utimesSync, writeFileSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" -import { PlanUpdateReminder } from "../plan-update-reminder.js" - -function createWorkspace() { - return mkdtempSync(join(tmpdir(), "wp-reminder-test-")) -} - -function createPlan(workspace, name, { content = "- [ ] 1단계\n", withRequiredDocs = true, updatedAt } = {}) { - const planDir = join(workspace, "docs", "plan", name) - mkdirSync(planDir, { recursive: true }) - - const planMdPath = join(planDir, "plan.md") - writeFileSync(planMdPath, content, "utf-8") - - if (withRequiredDocs) { - writeFileSync(join(planDir, "checklist.md"), "checklist", "utf-8") - } - - if (updatedAt) { - const targetTime = new Date(updatedAt) - utimesSync(planMdPath, targetTime, targetTime) - if (withRequiredDocs) { - utimesSync(join(planDir, "checklist.md"), targetTime, targetTime) - } - } - - return planMdPath -} - -async function runEvent(directory, status, content = "task") { - const plugin = await PlanUpdateReminder({ directory }) - return plugin.event({ - event: { - type: "todo.updated", - properties: { status, content }, - }, - }) -} - -describe("PlanUpdateReminder", () => { - it("warns on pending todo when no active plan exists", async () => { - const workspace = createWorkspace() - await expect(runEvent(workspace, "pending", "새 작업")).rejects.toThrow("Todo 생성 감지") - }) - - it("does not warn on pending todo when active plan exists", async () => { - const workspace = createWorkspace() - createPlan(workspace, "active") - await expect(runEvent(workspace, "pending", "새 작업")).resolves.toBeUndefined() - }) - - it("ignores plan directories missing required docs", async () => { - const workspace = createWorkspace() - createPlan(workspace, "broken", { withRequiredDocs: false }) - await expect(runEvent(workspace, "pending", "새 작업")).rejects.toThrow("Todo 생성 감지") - }) - - it("warns on completed todo with plan.md path", async () => { - const workspace = createWorkspace() - const planPath = createPlan(workspace, "active") - - await expect(runEvent(workspace, "completed", "1단계")).rejects.toThrow(planPath) - }) - - it("chooses most recently updated active plan", async () => { - const workspace = createWorkspace() - const oldTime = Date.now() - 60_000 - const newTime = Date.now() - createPlan(workspace, "old-plan", { updatedAt: oldTime }) - const latestPlanPath = createPlan(workspace, "latest-plan", { updatedAt: newTime }) - - await expect(runEvent(workspace, "completed", "1단계")).rejects.toThrow(latestPlanPath) - }) - - it("does nothing on completed todo when no active plan exists", async () => { - const workspace = createWorkspace() - await expect(runEvent(workspace, "completed", "1단계")).resolves.toBeUndefined() - }) - - describe("exempt tasks", () => { - it("skips pending reminder for docs-related task", async () => { - const workspace = createWorkspace() - await expect(runEvent(workspace, "pending", "CLAUDE.md 문서 수정")).resolves.toBeUndefined() - }) - - it("skips pending reminder for config file task", async () => { - const workspace = createWorkspace() - await expect(runEvent(workspace, "pending", "application.yml 설정 변경")).resolves.toBeUndefined() - }) - - it("skips pending reminder for build/CI task", async () => { - const workspace = createWorkspace() - await expect(runEvent(workspace, "pending", "CI/CD 파이프라인 수정")).resolves.toBeUndefined() - }) - - it("skips pending reminder for hook task", async () => { - const workspace = createWorkspace() - await expect(runEvent(workspace, "pending", ".claude/ 훅 설정 수정")).resolves.toBeUndefined() - }) - - it("skips completed reminder for exempt task even with active plan", async () => { - const workspace = createWorkspace() - createPlan(workspace, "active") - await expect(runEvent(workspace, "completed", "docs/ 문서 업데이트")).resolves.toBeUndefined() - }) - - it("still warns for non-exempt code task", async () => { - const workspace = createWorkspace() - await expect(runEvent(workspace, "pending", "인증 서비스 구현")).rejects.toThrow("Todo 생성 감지") - }) - }) -}) diff --git a/.opencode/plugins/__tests__/work-plan-enforcer.test.js b/.opencode/plugins/__tests__/work-plan-enforcer.test.js deleted file mode 100644 index 7d32561..0000000 --- a/.opencode/plugins/__tests__/work-plan-enforcer.test.js +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { mkdtempSync, mkdirSync, writeFileSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" -import { WorkPlanEnforcer } from "../work-plan-enforcer.js" - -function createWorkspace({ - planName = "task-a", - withActivePlan = false, - withRequiredDocs = true, - planContent = "- [ ] 1단계\n", -} = {}) { - const workspace = mkdtempSync(join(tmpdir(), "wp-enforcer-test-")) - if (!withActivePlan) return workspace - - const planDir = join(workspace, "docs", "plan", planName) - mkdirSync(planDir, { recursive: true }) - writeFileSync(join(planDir, "plan.md"), planContent, "utf-8") - - if (withRequiredDocs) { - writeFileSync(join(planDir, "checklist.md"), "checklist", "utf-8") - } - - return workspace -} - -async function runHook(directory, input, output) { - const plugin = await WorkPlanEnforcer({ directory }) - return plugin["tool.execute.before"](input, output) -} - -describe("WorkPlanEnforcer", () => { - it("blocks src file edit when active plan is missing", async () => { - const workspace = createWorkspace() - await expect( - runHook( - workspace, - { tool: "write" }, - { args: { filePath: "src/main/kotlin/TaskService.kt" } } - ) - ).rejects.toThrow("[work-plan]") - }) - - it("allows src file edit when active plan exists", async () => { - const workspace = createWorkspace({ withActivePlan: true }) - await expect( - runHook( - workspace, - { tool: "write" }, - { args: { filePath: "src/main/kotlin/TaskService.kt" } } - ) - ).resolves.toBeUndefined() - }) - - it("treats plan as inactive when required docs are missing", async () => { - const workspace = createWorkspace({ withActivePlan: true, withRequiredDocs: false }) - await expect( - runHook( - workspace, - { tool: "write" }, - { args: { filePath: "src/main/kotlin/TaskService.kt" } } - ) - ).rejects.toThrow("[work-plan]") - }) - - it("blocks bash-based src mutation without active plan", async () => { - const workspace = createWorkspace() - await expect( - runHook( - workspace, - { tool: "bash" }, - { args: { command: 'echo "x" > src/main/kotlin/TaskService.kt' } } - ) - ).rejects.toThrow("[work-plan]") - }) - - it("allows bash command when it only reads src files", async () => { - const workspace = createWorkspace() - await expect( - runHook(workspace, { tool: "bash" }, { args: { command: "cat src/main/kotlin/Task.kt" } }) - ).resolves.toBeUndefined() - }) - - it("allows bash mutation for exempt src config files", async () => { - const workspace = createWorkspace() - await expect( - runHook( - workspace, - { tool: "bash" }, - { args: { command: 'echo "spring: test" > src/main/resources/application.yml' } } - ) - ).resolves.toBeUndefined() - }) - - it("blocks apply_patch src edit without active plan", async () => { - const workspace = createWorkspace() - await expect( - runHook( - workspace, - { tool: "apply_patch" }, - { - args: { - patchText: "*** Begin Patch\n*** Update File: src/main/kotlin/Task.kt\n*** End Patch", - }, - } - ) - ).rejects.toThrow("[work-plan]") - }) -}) diff --git a/.opencode/plugins/block-dangerous.js b/.opencode/plugins/block-dangerous.js deleted file mode 100644 index 8870116..0000000 --- a/.opencode/plugins/block-dangerous.js +++ /dev/null @@ -1,31 +0,0 @@ -const BLOCKED_PATTERNS = [ - [/git\s+reset\s+--hard/i, "git reset --hard는 커밋되지 않은 작업을 삭제합니다"], - [/git\s+push\s+.*--force/i, "git push --force는 원격 히스토리를 덮어씁니다"], - [/git\s+push\s+.*-f\b/i, "git push -f는 원격 히스토리를 덮어씁니다"], - [/git\s+clean\s+-.*f/i, "git clean -f는 추적되지 않은 파일을 영구 삭제합니다"], - [/git\s+checkout\s+\.\s*$/i, "git checkout .은 모든 변경사항을 삭제합니다"], - [/git\s+restore\s+\.\s*$/i, "git restore .은 모든 변경사항을 삭제합니다"], - [/git\s+stash\s+drop/i, "git stash drop은 스태시를 영구 삭제합니다"], - [/git\s+stash\s+clear/i, "git stash clear는 모든 스태시를 삭제합니다"], - [/\brm\s+-rf\s+\/\s*(;|&&|\|\||$)/i, "rm -rf /는 매우 위험합니다"], - [/\brm\s+-rf\s+~/i, "rm -rf ~는 홈 디렉토리를 삭제합니다"], - [/\brm\s+-rf\s+\.\./i, "rm -rf ..은 상위 디렉토리를 삭제할 수 있습니다"], - [/\brm\s+-rf\s+\*/i, "rm -rf *는 위험합니다"], - [/DROP\s+DATABASE/i, "DROP DATABASE는 파괴적입니다"], - [/DROP\s+TABLE/i, "DROP TABLE은 파괴적입니다"], - [/TRUNCATE\s+TABLE/i, "TRUNCATE TABLE은 모든 데이터를 삭제합니다"], -] - -export const BlockDangerous = async () => { - return { - "tool.execute.before": async (input, output) => { - if (input.tool !== "bash") return - const command = output.args.command || "" - for (const [pattern, reason] of BLOCKED_PATTERNS) { - if (pattern.test(command)) { - throw new Error(`🚫 [Safety] 차단됨: ${reason}\n명령어: ${command}`) - } - } - }, - } -} diff --git a/.opencode/plugins/layer-doc-reminder.js b/.opencode/plugins/layer-doc-reminder.js deleted file mode 100644 index a0b0aaf..0000000 --- a/.opencode/plugins/layer-doc-reminder.js +++ /dev/null @@ -1,97 +0,0 @@ -import { existsSync } from "fs" -import { resolve } from "path" - -const LAYER_DOCS = { - domain: "docs/layers/domain.md", - application: "docs/layers/application.md", - infrastructure: "docs/layers/infrastructure.md", - presentation: "docs/layers/presentation.md", -} - -const SECURITY_DOC = "docs/spring-security-7.md" - -function detectLayer(filePath) { - for (const layer of Object.keys(LAYER_DOCS)) { - if (new RegExp(`(^|/)${layer}(/|$)`).test(filePath)) return layer - } - return null -} - -function isSecurityRelated(filePath) { - return ( - /(^|\/)common\/config(\/|$)/.test(filePath) && - filePath.toLowerCase().includes("security") - ) -} - -function isDocsPath(filePath) { - return /(^|\/)docs\//.test(filePath) -} - -function isTestPath(filePath) { - return /(^|\/)src\/test\//.test(filePath) || filePath.endsWith("Test.kt") -} - -function extractPathsFromPatchText(patchText) { - const paths = [] - const regex = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm - let match - while ((match = regex.exec(patchText)) !== null) { - paths.push(match[1].trim()) - } - return paths -} - -function collectFilePaths(input, output) { - const directPath = - output.args?.file_path || output.args?.filePath || output.args?.path || "" - const paths = [] - - if (directPath) paths.push(directPath) - - if (input.tool === "apply_patch" && typeof output.args?.patchText === "string") { - paths.push(...extractPathsFromPatchText(output.args.patchText)) - } - - return paths -} - -export const LayerDocReminder = async ({ directory }) => { - const reminded = new Set() - - return { - "tool.execute.before": async (input, output) => { - const filePaths = collectFilePaths(input, output) - if (filePaths.length === 0) return - - const messages = [] - - for (const filePath of filePaths) { - if (isDocsPath(filePath)) continue - if (isTestPath(filePath)) continue - - const layer = detectLayer(filePath) - if (layer && !reminded.has(layer)) { - reminded.add(layer) - const doc = LAYER_DOCS[layer] - if (existsSync(resolve(directory, doc))) { - messages.push( - `[${layer}] 이 레이어 첫 수정입니다. 먼저 ${doc} 를 읽으세요.` - ) - } - } - - if (isSecurityRelated(filePath) && !reminded.has("security")) { - reminded.add("security") - messages.push( - `[security] Security 설정 첫 수정입니다. 먼저 ${SECURITY_DOC} 를 읽으세요.` - ) - } - } - - if (messages.length > 0) { - throw new Error(messages.join("\n")) - } - }, - } -} diff --git a/.opencode/plugins/loop-hooks.js b/.opencode/plugins/loop-hooks.js new file mode 100644 index 0000000..04c9c10 --- /dev/null +++ b/.opencode/plugins/loop-hooks.js @@ -0,0 +1,132 @@ +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const HERE = dirname(fileURLToPath(import.meta.url)) +const CORE_DIR = resolve(HERE, "..", "..", "hooks", "core") + +const TOOL_MAP = { + bash: "Bash", + edit: "Edit", + write: "Write", + read: "Read", + apply_patch: "apply_patch", + task_create: "TaskCreate", + task_update: "TaskUpdate", +} + +function mapTool(name) { + return TOOL_MAP[name] ?? name +} + +function extractPathsFromPatchText(patchText) { + const paths = [] + const regex = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm + let match + while ((match = regex.exec(patchText)) !== null) { + paths.push(match[1].trim()) + } + return paths +} + +function adaptArgs(argsSource = {}) { + const args = { ...argsSource } + if (args.filePath && !args.file_path) args.file_path = args.filePath + return args +} + +function todoKey(todo, index) { + return `${index}:${todo.content ?? ""}` +} + +async function loadCore(name) { + return await import(resolve(CORE_DIR, `${name}.ts`)) +} + +async function runHooks(coreNames, ctx) { + for (const name of coreNames) { + const mod = await loadCore(name) + const result = await mod.check(ctx) + if (result.kind === "block") throw new Error(result.reason) + if (result.kind === "modify") { + Object.assign(ctx.args, result.updatedInput) + } + } +} + +export const LoopHooks = async ({ directory }) => { + const todoStatusBySession = new Map() + + return { + "tool.execute.before": async (input, output) => { + const tool = mapTool(input.tool) + const args = adaptArgs(output.args) + const ctx = { tool, args, cwd: directory } + + if (tool === "Bash") { + await runHooks(["block-dangerous", "work-plan-enforcer", "plan-completion-guard"], ctx) + return + } + + if (tool === "Edit" || tool === "Write" || tool === "apply_patch") { + if (input.tool === "apply_patch" && typeof output.args?.patchText === "string") { + ctx.args.patchText = output.args.patchText + for (const path of extractPathsFromPatchText(output.args.patchText)) { + const sub = { ...ctx, args: { ...ctx.args, file_path: path } } + await runHooks(["layer-doc-reminder", "work-plan-enforcer"], sub) + } + return + } + await runHooks(["layer-doc-reminder", "work-plan-enforcer"], ctx) + return + } + }, + + "tool.execute.after": async (input, output) => { + const tool = mapTool(input.tool) + if (tool !== "TaskCreate" && tool !== "TaskUpdate") return + const args = adaptArgs(input.args) + const ctx = { tool, args, cwd: directory } + await runHooks(["plan-update-reminder"], ctx) + }, + + event: async ({ event }) => { + if (event.type !== "todo.updated") return + const properties = event.properties ?? {} + const todos = Array.isArray(properties.todos) ? properties.todos : [] + if (todos.length === 0) return + + const sessionID = properties.sessionID ?? "default" + const previous = todoStatusBySession.get(sessionID) ?? new Map() + const next = new Map() + const reminders = [] + + for (const [index, todo] of todos.entries()) { + const status = todo.status + const key = todoKey(todo, index) + next.set(key, status) + + if (status !== "pending" && status !== "completed") continue + if (previous.get(key) === status) continue + + reminders.push({ + tool: status === "completed" ? "TaskUpdate" : "TaskCreate", + todo, + }) + } + + todoStatusBySession.set(sessionID, next) + + for (const { tool, todo } of reminders) { + await runHooks(["plan-update-reminder"], { + tool, + args: { + status: todo.status, + subject: todo.content, + content: todo.content, + }, + cwd: directory, + }) + } + }, + } +} diff --git a/.opencode/plugins/plan-update-reminder.js b/.opencode/plugins/plan-update-reminder.js deleted file mode 100644 index 8e6d0d5..0000000 --- a/.opencode/plugins/plan-update-reminder.js +++ /dev/null @@ -1,67 +0,0 @@ -import { findPrimaryActivePlan } from "../lib/work-plan-utils.js" - -// 비코드(예외) 작업 판별 패턴 (work-planning-rules.md 예외 기준) -// 태스크 subject/description에 이 문자열이 포함되면 계획 문서 리마인더를 건너뜀 -const EXEMPT_FILE_PATTERNS = [ - "/docs/", "docs/", - "/.claude/", ".claude/", - "/.opencode/", ".opencode/", - "README", "CLAUDE.md", - ".gradle.kts", ".gradle", - ".yml", ".yaml", - ".properties", ".toml", ".xml", - "Dockerfile", "docker-compose", - ".github/workflows", -] - -const EXEMPT_KEYWORD_RE = /문서|설정\s*파일|빌드|CI\/?CD|배포|deploy|hook|훅/i - -function isExemptTask(todo) { - const content = todo?.content || "" - const text = content - - for (const pattern of EXEMPT_FILE_PATTERNS) { - if (text.includes(pattern)) return true - } - - if (EXEMPT_KEYWORD_RE.test(text)) return true - - return false -} - -export const PlanUpdateReminder = async ({ directory }) => { - return { - event: async (input) => { - if (input.event.type !== "todo.updated") return - - const todo = input.event.properties - const status = todo?.status - - if (isExemptTask(todo)) return - - // Todo 완료 시: plan.md 체크 표시 업데이트 리마인드 - if (status === "completed") { - const activePlan = findPrimaryActivePlan(directory) - if (activePlan) { - throw new Error( - `[plan] Task 완료 감지. ` + - `다음 plan.md 의 해당 단계를 [x]로 업데이트하세요: ${activePlan.planMdPath}` - ) - } - } - - // Todo 생성 시: plan 디렉토리·문서 생성 여부 확인 - if (status === "pending") { - const activePlan = findPrimaryActivePlan(directory) - if (!activePlan) { - const subject = todo?.content || "" - throw new Error( - `[plan] Todo 생성 감지: "${subject}"\n` + - `docs/plan/{작업명}/ 에 plan.md, checklist.md 를 생성했는지 확인하세요. ` + - `(docs/work-planning-rules.md 참고)` - ) - } - } - }, - } -} diff --git a/.opencode/plugins/work-plan-enforcer.js b/.opencode/plugins/work-plan-enforcer.js deleted file mode 100644 index b8d88a4..0000000 --- a/.opencode/plugins/work-plan-enforcer.js +++ /dev/null @@ -1,124 +0,0 @@ -import { hasActivePlan } from "../lib/work-plan-utils.js" - -const EXEMPT_PATTERNS = ["/docs/", "/.claude/", "/.opencode/", "README", "CLAUDE.md"] - -const EXEMPT_EXTENSIONS = [ - ".gradle.kts", - ".gradle", - ".yml", - ".yaml", - ".properties", - ".toml", - ".xml", -] - -function normalizePath(filePath) { - return filePath.replace(/\\/g, "/") -} - -function isExempt(filePath) { - const normalized = normalizePath(filePath) - - for (const pattern of EXEMPT_PATTERNS) { - if (normalized.includes(pattern)) return true - } - - if (normalized.startsWith("docs/")) return true - if (normalized.startsWith(".claude/")) return true - if (normalized.startsWith(".opencode/")) return true - - for (const ext of EXEMPT_EXTENSIONS) { - if (normalized.endsWith(ext)) return true - } - return false -} - -function extractPathsFromPatchText(patchText) { - const paths = [] - const regex = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm - let match - while ((match = regex.exec(patchText)) !== null) { - paths.push(match[1].trim()) - } - return paths -} - -function extractSrcPathCandidates(command) { - const paths = [] - const regex = /(?:^|[\s"'])([^\s"'`]*src\/[^\s"'`]+)/g - let match - while ((match = regex.exec(command)) !== null) { - paths.push(match[1].replace(/[;|,&]+$/g, "")) - } - return paths -} - -function isBashSourceMutation(command) { - const writePatterns = [ - /\b(touch|mkdir|cp|mv|rm|install|truncate|dd)\b/i, - /\b(sed|perl)\b[\s\S]*\s-i\b/i, - /\btee\b/i, - /\b(cat|echo|printf)\b[\s\S]*>{1,2}/i, - />{1,2}\s*["']?[^\s"']*src\//i, - /\bpython(?:3)?\b[\s\S]*open\(\s*["'][^"']*src\/[^"']*["']\s*,\s*["'][wa]/i, - ] - - if (!/(^|\W)src\//.test(command)) return false - return writePatterns.some((pattern) => pattern.test(command)) -} - -function shouldBlockFilePath(filePath) { - const normalized = normalizePath(filePath) - if (isExempt(normalized)) return false - return /(^|\/)src\//.test(normalized) -} - -function ensureActivePlan(directory) { - if (hasActivePlan(directory)) return - - throw new Error( - `[work-plan] 소스 코드 수정이 차단되었습니다. ` + - `docs/plan/{작업명}/ 에 plan.md, checklist.md 를 먼저 생성하세요. ` + - `(docs/work-planning-rules.md 참고)` - ) -} - -function collectFilePaths(input, output) { - const directPath = - output.args?.file_path || output.args?.filePath || output.args?.path || "" - const paths = [] - - if (directPath) paths.push(directPath) - - if (input.tool === "apply_patch" && typeof output.args?.patchText === "string") { - paths.push(...extractPathsFromPatchText(output.args.patchText)) - } - - return paths -} - -export const WorkPlanEnforcer = async ({ directory }) => { - return { - "tool.execute.before": async (input, output) => { - if (input.tool === "bash") { - const command = output.args?.command || "" - if (!isBashSourceMutation(command)) return - - const srcPaths = extractSrcPathCandidates(command) - const hasNonExemptSrcPath = srcPaths.some((path) => shouldBlockFilePath(path)) - if (!hasNonExemptSrcPath) return - - ensureActivePlan(directory) - return - } - - const filePaths = collectFilePaths(input, output) - if (filePaths.length === 0) return - - for (const filePath of filePaths) { - if (!shouldBlockFilePath(filePath)) continue - ensureActivePlan(directory) - } - }, - } -} diff --git a/CLAUDE.md b/CLAUDE.md index 44e3185..e93ab9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,10 @@ 작업 계획 문서 작성 시 별도 지시가 없으면 docs/ 디렉토리에 마크다운 파일로 작성. +## 런타임 의존성 + +- **Bun 1.x** — 훅 디스패처(`hooks/adapters/claude/run.mjs`)와 OpenCode 플러그인이 Bun 런타임으로 실행됩니다. Claude Code 훅과 OpenCode 플러그인 양쪽 모두 `hooks/core/*.ts`를 공유하므로 Bun이 PATH에 있어야 합니다. + ## 작업 계획 프로세스 모든 코드 작업은 이슈 생성 → 계획 → 실행 → 검증 순서로 진행합니다. 상세 규칙은 @docs/work-planning-rules.md 참고. diff --git a/docs/plan/#46-hook-unification/checklist.md b/docs/plan/#46-hook-unification/checklist.md new file mode 100644 index 0000000..d611008 --- /dev/null +++ b/docs/plan/#46-hook-unification/checklist.md @@ -0,0 +1,46 @@ +# 훅 로직 단일화 검증 체크리스트 + +> Issue: #46 + +## 필수 항목 + +### 코어 모듈 +- [x] `hooks/core/block-dangerous.ts` 단위 테스트 통과 +- [x] `hooks/core/work-plan-enforcer.ts` 단위 테스트 통과 +- [x] `hooks/core/plan-completion-guard.ts` 단위 테스트 통과 +- [x] `hooks/core/plan-update-reminder.ts` 단위 테스트 통과 +- [x] `hooks/core/layer-doc-reminder.ts` 단위 테스트 통과 +- [x] `bun test` 전체 통과 (127 pass / 0 fail) + +### 기능 동등성 (Claude Code & OpenCode 양쪽) +- [x] `git push --force` → 차단 메시지 (block-dangerous 테스트) +- [x] `rm -rf ~` → 차단 (block-dangerous 테스트) +- [x] 활성 plan 없는 상태에서 `src/` 파일 편집 → 차단 (work-plan-enforcer 테스트) +- [x] 활성 plan 없는 상태에서 `docs/` 파일 편집 → 통과 (work-plan-enforcer 테스트) +- [x] 활성 plan 있는 상태에서 `src/` 파일 편집 → 통과 (work-plan-enforcer 테스트) +- [x] 미완료 `plan.md` 있는 상태에서 `gh pr create`/`git push` → 차단 (plan-completion-guard 테스트) +- [x] `domain/model/` 파일 편집 시 레이어 문서 리마인더 출력 (layer-doc-reminder 테스트) +- [x] `TaskCreate`/`TaskUpdate` 이후 plan 업데이트 리마인더 출력 (plan-update-reminder 테스트 + 라이브 동작으로 본 세션에서 반복 확인) +- [x] OpenCode 어댑터 시나리오 14건 통과 (`.opencode/plugins/__tests__/loop-hooks.test.js`) +- [x] Claude dispatcher 라이브 스모크 테스트 (rm -rf / 차단, ls -la 통과) + +### 구조 정리 +- [x] `.claude/hooks/`의 가드·리마인더 Python 5종 + 대응 테스트 삭제 +- [x] `.opencode/plugins/`의 가드·리마인더 JS 4종 + 대응 테스트 삭제, `loop-hooks.js` 한 개로 통합 +- [x] `.opencode/lib/work-plan-utils.js` 삭제 (`hooks/core/lib/plan-scanner.ts`로 이전) +- [x] `.claude/settings.json`이 `bun run.mjs ` 형태로 호출 +- [x] `.opencode` 패키지가 단일 플러그인(loop-hooks)만 등록 (markdown-formatter는 포매팅 범위 외 보존) + +### 회귀 검증 +- [x] OpenCode SDK의 `todo.updated` 공식 shape(`properties.todos`) 테스트 추가 +- [x] pending todo 이벤트가 `TaskCreate` 리마인더를 발생시킴 +- [x] completed todo 이벤트가 활성 plan 경로 리마인더를 발생시킴 +- [x] in_progress todo 이벤트는 silent +- [x] 관련 테스트 통과 + +### 범위 외 보존 확인 +- [x] 포매팅 훅(`markdown_formatter.py`, `ktlint-format.sh`, `markdown-formatter.js`)은 변경되지 않음 + +## 선택 항목 +- [x] CLAUDE.md / README에 Bun 1.x 의존성 명시 +- [ ] 글로벌 hook(`~/.claude/`, `~/.config/opencode/`) 통합 여부 결정 diff --git a/docs/plan/#46-hook-unification/plan.md b/docs/plan/#46-hook-unification/plan.md new file mode 100644 index 0000000..90470dd --- /dev/null +++ b/docs/plan/#46-hook-unification/plan.md @@ -0,0 +1,274 @@ +# 훅 로직 단일화 계획 + +> Issue: #46 + +## 단계 + +- [x] 1. `hooks/` 패키지 골격 구성 (`package.json`, `tsconfig.json`, `__tests__/`) +- [x] 2. 공통 라이브러리 추출 — `lib/plan-scanner.ts`, `lib/path.ts` +- [x] 3. block-dangerous 코어 포팅 + 테스트 +- [x] 4. layer-doc-reminder 코어 포팅 + 테스트 +- [x] 5. plan-update-reminder 코어 포팅 + 테스트 +- [x] 6. work-plan-enforcer 코어 포팅 + 테스트 +- [x] 7. plan-completion-guard 코어 포팅 + 테스트 +- [x] 8. Claude dispatcher (`adapters/claude/run.mjs`) 작성 + `.claude/settings.json` 갱신 +- [x] 9. OpenCode 단일 플러그인 (`adapters/opencode/plugin.ts`) 작성 +- [x] 10. 양 시스템 시나리오 검증 (checklist.md) +- [x] 11. 기존 Python 가드/리마인더 훅 + JS 플러그인 삭제 + +## 회귀 수정 + +- [x] 12. OpenCode `todo.updated` 공식 이벤트 shape 재현 테스트 추가 +- [x] 13. OpenCode 훅 어댑터가 `properties.todos` 배열을 처리하도록 수정 +- [x] 14. 관련 테스트와 자율 검증 핵심 경로 재실행 + +## 1. 배경 + +현재 동일한 가드/리마인더 로직이 두 언어로 중복 구현되어 있다. + +| 훅 | Claude (`.claude/hooks/`) | OpenCode (`.opencode/plugins/`) | +|----|---------------------------|--------------------------------| +| 위험 명령 차단 | `block_dangerous.py` | `block-dangerous.js` | +| 작업 계획 강제 | `work_plan_enforcer.py` | `work-plan-enforcer.js` | +| 계획 완료 가드 (PR/push) | `plan_completion_guard.py` | **누락** | +| 계획 업데이트 리마인더 | `plan_update_reminder.py` | `plan-update-reminder.js` | +| 레이어 문서 리마인더 | `layer_doc_reminder.py` | `layer-doc-reminder.js` | + +> 포매팅(`markdown_formatter`, `ktlint-format` 등)은 통합 대상에서 제외한다. +> OpenCode는 [내장 포매터 시스템](https://opencode.ai/docs/ko/formatters/)으로 ktlint·prettier 등을 `opencode.json`의 `formatter` 섹션에서 자동 처리한다. Claude 측 포매팅 훅은 그대로 두되, 동등한 동작은 OpenCode 내장 메커니즘에 위임한다. + +이미 드리프트가 발생한 상태: +- `plan_completion_guard`는 OpenCode 측에 없음 +- `BLOCKED_PATTERNS` 메시지가 미세하게 다름 (예: `git checkout .` 메시지 표현) + +## 2. 두 훅 시스템 비교 + +| 항목 | Claude Code | OpenCode | +|------|-------------|----------| +| 진입점 | `settings.json` → `hooks.[].hooks[].command` | `.opencode/plugins/*.{js,ts}` | +| 실행 방식 | 매 호출마다 외부 프로세스 spawn | Bun 인-프로세스 | +| 입력 | stdin JSON (`tool_name`, `tool_input`, `cwd`, …) | `(input, output)` 객체 | +| 차단 | exit code 2 + stderr | `throw new Error(...)` | +| 분기/주입 | stdout JSON (`hookSpecificOutput.additionalContext`, `permissionDecision`, `updatedInput`) | `output.args` 변형 | +| 외부 명령 호출 | 임의 가능 (이미 프로세스) | Bun `$` 쉘 헬퍼 | +| 의존성 공유 | 임의 (PATH 기준) | `package.json` + `import` | +| 환경 변수 | `CLAUDE_PROJECT_DIR` 등 | 핸들러 인자 `directory` | + +핵심: 두 시스템 모두 **언어 무관**(read stdin / write stdout 또는 ESM export)하며 로직 입력은 사실상 `(toolName, args, cwd)`로 동치. + +## 3. 접근 옵션 비교 + +| 옵션 | 설명 | 장점 | 단점 | +|------|------|------|------| +| **A** | 현 상태 유지 | 변경 없음 | 드리프트 가속 | +| **B** | 두 언어로 포팅 + pure-logic 라이브러리만 추출 | 양쪽 인-프로세스 | 두 포팅 유지 비용 그대로 | +| **C** | **단일 JS/TS 코어 + 어댑터 2종** | 진실의 단일 소스, OpenCode는 import, Claude는 `bun run.mjs` 한 줄 호출 | Claude 측에 Bun 의존 강제 (이미 OpenCode가 사용 중) | +| **D** | 단일 Python 코어 + OpenCode가 spawn | Claude 측 변경 최소 | OpenCode 인-프로세스 이점 상실, Bun→Python 호출 부자연 | + +**권장: 옵션 C.** +- Bun은 이미 `.opencode/bun.lock`으로 프로젝트 런타임에 포함. +- Bun 콜드스타트(~30ms)는 Python(~50ms)과 비슷하거나 빠름 → Claude 측 spawn 비용 악화 없음. +- Bun은 `.ts` 직접 실행 가능 → 트랜스파일 단계 불필요. + +## 4. 목표 디렉토리 구조 + +```text +hooks/ +├── package.json # bun, type: module, ../.opencode와 별개 패키지 +├── tsconfig.json +├── core/ # 단일 진실 소스 +│ ├── block-dangerous.ts +│ ├── work-plan-enforcer.ts +│ ├── plan-completion-guard.ts +│ ├── plan-update-reminder.ts +│ ├── layer-doc-reminder.ts +│ └── lib/ +│ ├── plan-scanner.ts # find/has Active/Incomplete plan +│ └── path.ts # normalize, isExempt +├── adapters/ +│ ├── claude/run.mjs # `bun .../run.mjs ` 단일 dispatcher +│ └── opencode/plugin.ts # 모든 코어를 OpenCode 이벤트로 매핑한 단일 플러그인 +└── __tests__/ # bun test + ├── block-dangerous.test.ts + ├── work-plan-enforcer.test.ts + └── ... +``` + +각 코어 모듈의 시그니처를 통일: + +```ts +export type HookCtx = { + tool: string // "Bash" | "Edit" | "Write" | ... + args: Record + cwd: string + phase: "before" | "after" +} + +export type HookResult = + | { kind: "allow" } + | { kind: "block"; reason: string } + | { kind: "context"; message: string } // additionalContext / 콘솔 출력 + | { kind: "modify"; updatedInput: Record } + | { kind: "sideEffect"; run: () => Promise } // 포매터처럼 파일 수정만 하는 경우 + +export function check(ctx: HookCtx): HookResult | Promise +``` + +## 5. Claude 어댑터 (단일 dispatcher) + +`hooks/adapters/claude/run.mjs`: + +```js +#!/usr/bin/env bun +import { argv, stdin, stderr, stdout, env, exit } from "process" + +const [, , hookName, phase = "before"] = argv +const raw = await new Response(stdin).text() +const data = JSON.parse(raw || "{}") +const cwd = data.cwd ?? env.CLAUDE_PROJECT_DIR ?? process.cwd() + +const mod = await import(`../../core/${hookName}.ts`) +const result = await mod.check({ + tool: data.tool_name, + args: data.tool_input ?? {}, + cwd, + phase, +}) + +switch (result.kind) { + case "block": + stderr.write(result.reason) + exit(2) + case "context": + stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: data.hook_event_name, + additionalContext: result.message, + }, + })) + exit(0) + case "modify": + stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: data.hook_event_name, + updatedInput: result.updatedInput, + }, + })) + exit(0) + case "sideEffect": + await result.run() + exit(0) + case "allow": + default: + exit(0) +} +``` + +`.claude/settings.json` 항목은 다음 패턴으로 단순화: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" block-dangerous before" }, + { "type": "command", "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" work-plan-enforcer before" }, + { "type": "command", "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" plan-completion-guard before" } + ] + }, + { + "matcher": "Edit|Write", + "hooks": [ + { "type": "command", "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" layer-doc-reminder before" }, + { "type": "command", "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" work-plan-enforcer before" } + ] + } + ], + "PostToolUse": [ + { + "matcher": "TaskCreate|TaskUpdate", + "hooks": [ + { "type": "command", "command": "bun \"$CLAUDE_PROJECT_DIR/hooks/adapters/claude/run.mjs\" plan-update-reminder after" } + ] + } + ] + } +} +``` + +## 6. OpenCode 어댑터 (단일 플러그인) + +`hooks/adapters/opencode/plugin.ts`: + +```ts +import * as BlockDangerous from "../../core/block-dangerous" +import * as WorkPlanEnforcer from "../../core/work-plan-enforcer" +import * as PlanCompletionGuard from "../../core/plan-completion-guard" +import * as LayerDocReminder from "../../core/layer-doc-reminder" +import * as PlanUpdateReminder from "../../core/plan-update-reminder" + +const BEFORE = [BlockDangerous, WorkPlanEnforcer, PlanCompletionGuard, LayerDocReminder] +const AFTER = [PlanUpdateReminder] + +const TOOL_MAP: Record = { + bash: "Bash", edit: "Edit", write: "Write", read: "Read", + // 필요시 확장 +} + +export const LoopHooks = async ({ directory }) => ({ + "tool.execute.before": async (input, output) => { + const ctx = { + tool: TOOL_MAP[input.tool] ?? input.tool, + args: output.args, + cwd: directory, + phase: "before" as const, + } + for (const mod of BEFORE) { + const r = await mod.check(ctx) + if (r.kind === "block") throw new Error(r.reason) + if (r.kind === "modify") Object.assign(output.args, r.updatedInput) + // context/sideEffect는 OpenCode에서 출력 채널이 없으므로 console.log 또는 무시 + } + }, + "tool.execute.after": async (input, output) => { + const ctx = { tool: TOOL_MAP[input.tool] ?? input.tool, args: output.args, cwd: directory, phase: "after" as const } + for (const mod of AFTER) await mod.check(ctx) + }, +}) +``` + +기존 `.opencode/plugins/{block-dangerous,work-plan-enforcer,layer-doc-reminder,plan-update-reminder}.js` 4개를 제거하고 위 단일 플러그인 한 개만 남긴다. `.opencode/plugins/markdown-formatter.js`는 OpenCode 내장 포매터로 대체 가능한지 검토 후 별도 결정. `.opencode/lib/work-plan-utils.js`는 `hooks/core/lib/plan-scanner.ts`로 흡수. + +## 7. 단계별 작업 + +1. **이슈 생성** — `gh issue create --title "훅 로직 단일화" --label refactor`. 본문에 본 문서 링크. +2. **`hooks/` 패키지 골격 구성** — `package.json` (`@types/node`, dev: `bun-types`), `tsconfig.json`, `__tests__/` 디렉토리. +3. **공통 라이브러리 추출** — `lib/plan-scanner.ts`(현 `work-plan-utils.js` + `work_plan_enforcer.has_active_plan` + `plan_completion_guard.find_incomplete_plans` 통합), `lib/path.ts`(`normalizePath`, `isExempt`). +4. **코어 5개 포팅 (TDD)** — 모듈마다 (a) 기존 Python 테스트 케이스를 Bun test로 옮기고 (b) `check()` 시그니처로 구현. 순서: + - block-dangerous (가장 단순, 의존성 없음) + - layer-doc-reminder + - plan-update-reminder + - work-plan-enforcer (plan-scanner 의존) + - plan-completion-guard (plan-scanner 의존) +5. **Claude dispatcher (`run.mjs`) 작성 + `.claude/settings.json` 갱신** (포매팅 훅 항목은 그대로 유지). +6. **OpenCode 단일 플러그인 작성** + 기존 가드/리마인더 플러그인 4개 + `.opencode/lib/` 제거. +7. **양 시스템 시나리오 검증** — 아래 체크리스트. +8. **기존 Python 가드/리마인더 훅 + JS 플러그인 삭제 커밋** (포매팅 관련 파일은 별도 작업). + +## 8. 검증 체크리스트 + +상세 항목은 [`checklist.md`](checklist.md) 참고. 모두 통과. + +## 9. 트레이드오프 및 리스크 + +- **Bun 의존성을 Claude 측에도 강제** — Claude Code만 쓰던 기여자도 Bun 설치 필요. 다만 이미 OpenCode 사용자에겐 필요. README/CLAUDE.md에 "Bun 1.x 필요" 명시. +- **`.ts` 직접 실행** — Bun이 처리하므로 빌드 단계 없음. 단, Node.js만으로 디버깅 시도하면 동작 안 함 → README 명시. +- **인-프로세스 vs 서브프로세스** — Claude 측은 어차피 spawn. Bun 콜드스타트는 ~30ms, 기존 Python과 동급. +- **기능 동등성 회귀 위험** — 단계 4의 TDD로 기존 Python 테스트 케이스를 모두 옮기면 큰 회귀는 없을 것. 다만 `markdown-formatter`는 Claude 측이 PostToolUse Edit·Write 시점에 도는 반면 OpenCode는 `file.edited`로 동기화 → 두 시스템에서 같은 트리거가 가능한지 확인 필요. (Claude의 `PostToolUse` Edit·Write ≈ OpenCode의 `file.edited`. 충분히 동등.) + +## 10. 후속 과제 (Out of Scope) + +- 글로벌(`~/.claude/`, `~/.config/opencode/`) 훅까지 공유할지 — 현재는 프로젝트 단위로 한정. +- 다른 에이전트 시스템(Codex, Cursor 등)이 추가될 때 어댑터 한 개 더 작성으로 끝나도록 코어 시그니처를 의식적으로 도구 명세에 종속시키지 않음 (`HookCtx`가 이미 일반화됨). diff --git a/hooks/.gitignore b/hooks/.gitignore new file mode 100644 index 0000000..d286b7c --- /dev/null +++ b/hooks/.gitignore @@ -0,0 +1,2 @@ +node_modules +bun.lock diff --git a/hooks/__tests__/block-dangerous.test.ts b/hooks/__tests__/block-dangerous.test.ts new file mode 100644 index 0000000..b2fbfd4 --- /dev/null +++ b/hooks/__tests__/block-dangerous.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" + +import { check } from "../core/block-dangerous" + +function bash(command: string) { + return check({ tool: "Bash", args: { command }, cwd: "/x" }) +} + +describe("block-dangerous: hard block", () => { + test("rm -rf /", () => { + expect(bash("rm -rf /").kind).toBe("block") + }) + test("rm -rf / && echo done", () => { + expect(bash("rm -rf / && echo done").kind).toBe("block") + }) + test("rm -rf ~", () => { + expect(bash("rm -rf ~").kind).toBe("block") + }) + test("rm -rf *", () => { + expect(bash("rm -rf *").kind).toBe("block") + }) + test("rm -rf ../sibling", () => { + expect(bash("rm -rf ../sibling").kind).toBe("block") + }) + test("git push --force", () => { + expect(bash("git push --force origin feature").kind).toBe("block") + }) + test("git push -f", () => { + expect(bash("git push -f origin main").kind).toBe("block") + }) + test("git reset --hard", () => { + expect(bash("git reset --hard HEAD~1").kind).toBe("block") + }) + test("git clean -fd", () => { + expect(bash("git clean -fd").kind).toBe("block") + }) + test("git checkout .", () => { + expect(bash("git checkout .").kind).toBe("block") + }) + test("git restore .", () => { + expect(bash("git restore .").kind).toBe("block") + }) + test("git stash drop", () => { + expect(bash("git stash drop").kind).toBe("block") + }) + test("git stash clear", () => { + expect(bash("git stash clear").kind).toBe("block") + }) + test("DROP TABLE", () => { + expect(bash("psql -c 'DROP TABLE users'").kind).toBe("block") + }) + test("DROP DATABASE", () => { + expect(bash("psql -c 'DROP DATABASE x'").kind).toBe("block") + }) + test("TRUNCATE TABLE", () => { + expect(bash("psql -c 'TRUNCATE TABLE x'").kind).toBe("block") + }) +}) + +describe("block-dangerous: allow", () => { + test("ls -la", () => { + expect(bash("ls -la").kind).toBe("allow") + }) + test("git status", () => { + expect(bash("git status").kind).toBe("allow") + }) + test("git push origin main", () => { + expect(bash("git push origin main").kind).toBe("allow") + }) + test("rm file.txt", () => { + expect(bash("rm file.txt").kind).toBe("allow") + }) + test("./gradlew build", () => { + expect(bash("./gradlew build").kind).toBe("allow") + }) + test("Edit tool ignored", () => { + expect(check({ tool: "Edit", args: { file_path: "/x" }, cwd: "/x" }).kind).toBe("allow") + }) + test("empty command allowed", () => { + expect(bash("").kind).toBe("allow") + }) + test("lowercase bash tool name (opencode) is recognized", () => { + expect(check({ tool: "bash", args: { command: "rm -rf /" }, cwd: "/x" }).kind).toBe("block") + }) +}) diff --git a/hooks/__tests__/claude-dispatcher.test.ts b/hooks/__tests__/claude-dispatcher.test.ts new file mode 100644 index 0000000..3ed9730 --- /dev/null +++ b/hooks/__tests__/claude-dispatcher.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { spawnSync } from "node:child_process" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const HERE = dirname(fileURLToPath(import.meta.url)) +const HOOKS_DIR = resolve(HERE, "..") +const PROJECT_DIR = resolve(HOOKS_DIR, "..") +const DISPATCHER = join(HOOKS_DIR, "adapters", "claude", "run.mjs") + +let tmp: string + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "claude-dispatcher-")) +}) + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) + +function runDispatcher(hookName: string, payload: unknown, inputOverride?: string) { + return spawnSync("bun", [DISPATCHER, hookName], { + cwd: PROJECT_DIR, + env: { ...process.env, CLAUDE_PROJECT_DIR: tmp }, + input: inputOverride ?? JSON.stringify(payload), + encoding: "utf8", + }) +} + +function makeActivePlan(name = "active") { + const dir = join(tmp, "docs", "plan", name) + mkdirSync(dir, { recursive: true }) + const planMdPath = join(dir, "plan.md") + writeFileSync(planMdPath, "- [ ] step") + writeFileSync(join(dir, "checklist.md"), "# checklist") + return planMdPath +} + +describe("Claude dispatcher", () => { + test("blocks dangerous bash command with Claude exit code 2", () => { + const result = runDispatcher("block-dangerous", { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "rm -rf /" }, + cwd: tmp, + session_id: "session-danger", + }) + + expect(result.status).toBe(2) + expect(result.stderr).toContain("Safety Hook") + expect(result.stderr).toContain("rm -rf /") + }) + + test("allows safe bash command with exit code 0", () => { + const result = runDispatcher("block-dangerous", { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "ls -la" }, + cwd: tmp, + session_id: "session-safe", + }) + + expect(result.status).toBe(0) + expect(result.stderr).toBe("") + }) + + test("passes cwd and tool_input to plan-update-reminder", () => { + const planMdPath = makeActivePlan() + const result = runDispatcher("plan-update-reminder", { + hook_event_name: "PostToolUse", + tool_name: "TaskUpdate", + tool_input: { status: "completed" }, + cwd: tmp, + session_id: "session-plan", + }) + + expect(result.status).toBe(2) + expect(result.stderr).toContain(planMdPath) + }) + + test("ignores malformed JSON input", () => { + const result = runDispatcher("block-dangerous", {}, "{not-json") + + expect(result.status).toBe(0) + expect(result.stderr).toBe("") + }) + + test("unknown hook name exits without blocking", () => { + const result = runDispatcher("unknown-hook", { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "rm -rf /" }, + cwd: tmp, + session_id: "session-unknown", + }) + + expect(result.status).toBe(0) + expect(result.stderr).toContain("unknown hook") + }) +}) diff --git a/hooks/__tests__/layer-doc-reminder.test.ts b/hooks/__tests__/layer-doc-reminder.test.ts new file mode 100644 index 0000000..56cb56b --- /dev/null +++ b/hooks/__tests__/layer-doc-reminder.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { + __testing, + check, + detectLayer, + isSecurityRelated, +} from "../core/layer-doc-reminder" + +let tmp: string + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "layer-reminder-")) + __testing.setReminderDir(tmp) + __testing.resetInProcess() +}) + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) + +function edit(filePath: string, sessionId?: string) { + return check({ tool: "Edit", args: { file_path: filePath }, cwd: "/proj", sessionId }) +} + +describe("detectLayer", () => { + test("domain", () => expect(detectLayer("/p/task/domain/model/Task.kt")).toBe("domain")) + test("application", () => + expect(detectLayer("/p/task/application/service/TaskService.kt")).toBe("application")) + test("infrastructure", () => + expect(detectLayer("/p/task/infrastructure/persistence/TaskTable.kt")).toBe("infrastructure")) + test("presentation", () => + expect(detectLayer("/p/task/presentation/controller/TaskController.kt")).toBe("presentation")) + test("unrelated", () => expect(detectLayer("/p/build.gradle.kts")).toBeNull()) + test("common/config not a layer", () => + expect(detectLayer("/p/common/config/SecurityConfig.kt")).toBeNull()) +}) + +describe("isSecurityRelated", () => { + test("SecurityConfig.kt under common/config", () => + expect(isSecurityRelated("/p/common/config/SecurityConfig.kt")).toBe(true)) + test("case insensitive", () => + expect(isSecurityRelated("/p/common/config/SECURITY.kt")).toBe(true)) + test("non-security under common/config", () => + expect(isSecurityRelated("/p/common/config/WebMvcConfig.kt")).toBe(false)) + test("security outside common/config", () => + expect(isSecurityRelated("/p/auth/domain/SecurityToken.kt")).toBe(false)) +}) + +describe("session-keyed reminders", () => { + test("first domain edit blocks", () => { + const r = edit("/p/task/domain/model/Task.kt", "s1") + expect(r.kind).toBe("block") + expect(r.kind === "block" && r.reason.includes("domain")).toBe(true) + expect(r.kind === "block" && r.reason.includes("docs/layers/domain.md")).toBe(true) + }) + test("second domain edit in same session passes", () => { + edit("/p/task/domain/model/Task.kt", "s1") + expect(edit("/p/task/domain/model/Other.kt", "s1").kind).toBe("allow") + }) + test("different session blocks again", () => { + edit("/p/task/domain/model/Task.kt", "sA") + expect(edit("/p/task/domain/model/Task.kt", "sB").kind).toBe("block") + }) + test("different layer in same session still blocks", () => { + edit("/p/task/domain/model/Task.kt", "s1") + expect(edit("/p/task/application/TaskService.kt", "s1").kind).toBe("block") + }) + test("docs path always passes", () => { + expect(edit("/p/docs/layers/domain.md", "s1").kind).toBe("allow") + }) + test("Test.kt suffix passes", () => { + expect(edit("/p/task/domain/model/TaskTest.kt", "s1").kind).toBe("allow") + }) + test("src/test/ passes", () => { + expect(edit("/p/src/test/kotlin/task/domain/model/Task.kt", "s1").kind).toBe("allow") + }) + test("BC named test still triggers reminder", () => { + expect(edit("/p/test/domain/model/Task.kt", "s1").kind).toBe("block") + }) + test("unrelated file passes", () => { + expect(edit("/p/build.gradle.kts", "s1").kind).toBe("allow") + }) + test("security config blocks", () => { + const r = edit("/p/common/config/SecurityConfig.kt", "s1") + expect(r.kind).toBe("block") + expect(r.kind === "block" && r.reason.toLowerCase().includes("security")).toBe(true) + expect(r.kind === "block" && r.reason.includes("spring-security-7.md")).toBe(true) + }) + test("empty file_path passes", () => { + expect(edit("", "s1").kind).toBe("allow") + }) + test("non Edit/Write tool passes", () => { + expect(check({ tool: "Bash", args: { command: "x" }, cwd: "/p", sessionId: "s1" }).kind).toBe( + "allow", + ) + }) + test("in-process fallback when no sessionId", () => { + expect(edit("/p/task/domain/model/A.kt").kind).toBe("block") + expect(edit("/p/task/domain/model/B.kt").kind).toBe("allow") + }) +}) diff --git a/hooks/__tests__/path.test.ts b/hooks/__tests__/path.test.ts new file mode 100644 index 0000000..a7a3d07 --- /dev/null +++ b/hooks/__tests__/path.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" + +import { isExempt, isSrcPath, normalizePath, shouldBlockSrcPath } from "../core/lib/path" + +describe("normalizePath", () => { + test("converts backslashes to forward slashes", () => { + expect(normalizePath("C:\\src\\main\\Task.kt")).toBe("C:/src/main/Task.kt") + }) +}) + +describe("isExempt", () => { + test("matches docs path", () => { + expect(isExempt("/proj/docs/plan/a.md")).toBe(true) + }) + test("matches .claude path", () => { + expect(isExempt("/proj/.claude/hooks/x.py")).toBe(true) + }) + test("matches .opencode path", () => { + expect(isExempt("/proj/.opencode/plugins/x.js")).toBe(true) + }) + test("matches README", () => { + expect(isExempt("/proj/README.md")).toBe(true) + }) + test("matches CLAUDE.md", () => { + expect(isExempt("/proj/CLAUDE.md")).toBe(true) + }) + test("matches build.gradle.kts", () => { + expect(isExempt("/proj/build.gradle.kts")).toBe(true) + }) + test("matches application.yml", () => { + expect(isExempt("/proj/src/main/resources/application.yml")).toBe(true) + }) + test("does not match plain kotlin source", () => { + expect(isExempt("/proj/src/main/kotlin/Task.kt")).toBe(false) + }) +}) + +describe("isSrcPath", () => { + test("detects src/ at root", () => { + expect(isSrcPath("src/main/kotlin/Task.kt")).toBe(true) + }) + test("detects src/ in absolute path", () => { + expect(isSrcPath("/proj/src/main/kotlin/Task.kt")).toBe(true) + }) + test("normalizes backslashes", () => { + expect(isSrcPath("C:\\proj\\src\\Task.kt")).toBe(true) + }) + test("rejects non-src", () => { + expect(isSrcPath("/proj/docs/x.md")).toBe(false) + }) +}) + +describe("shouldBlockSrcPath", () => { + test("blocks src kotlin file", () => { + expect(shouldBlockSrcPath("/proj/src/main/kotlin/Task.kt")).toBe(true) + }) + test("does not block exempt yml even under src", () => { + expect(shouldBlockSrcPath("/proj/src/main/resources/application.yml")).toBe(false) + }) +}) diff --git a/hooks/__tests__/plan-completion-guard.test.ts b/hooks/__tests__/plan-completion-guard.test.ts new file mode 100644 index 0000000..b5908d9 --- /dev/null +++ b/hooks/__tests__/plan-completion-guard.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { check } from "../core/plan-completion-guard" + +let tmp: string + +function makePlan(name: string, complete = true, extras: string[] = []) { + const dir = join(tmp, "docs", "plan", name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "plan.md"), `- ${complete ? "[x]" : "[ ]"} step 1`) + writeFileSync(join(dir, "checklist.md"), "# c") + for (const e of extras) writeFileSync(join(dir, e), "# x") +} + +function bash(command: string) { + return check({ tool: "Bash", args: { command }, cwd: tmp }) +} + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "plan-guard-")) +}) + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) + +describe("plan-completion-guard", () => { + test("non-Bash always allowed", () => { + expect(check({ tool: "Edit", args: { file_path: "x" }, cwd: tmp }).kind).toBe("allow") + }) + test("non PR/push allowed", () => { + expect(bash("git status").kind).toBe("allow") + }) + test("gh pr create with incomplete plan blocked", () => { + makePlan("wip", false) + const r = bash('gh pr create --title "x"') + expect(r.kind).toBe("block") + expect(r.kind === "block" && r.reason.includes("plan-guard")).toBe(true) + expect(r.kind === "block" && r.reason.includes("wip")).toBe(true) + }) + test("git push with incomplete plan blocked", () => { + makePlan("wip", false) + const r = bash("git push -u origin main") + expect(r.kind).toBe("block") + }) + test("gh pr create with complete plan allowed", () => { + makePlan("done", true) + expect(bash('gh pr create --title "x"').kind).toBe("allow") + }) + test("no plans at all allowed", () => { + mkdirSync(join(tmp, "docs", "plan"), { recursive: true }) + expect(bash('gh pr create --title "x"').kind).toBe("allow") + }) + test("any incomplete blocks even with completed siblings", () => { + makePlan("done", true) + makePlan("wip", false) + const r = bash('gh pr create --title "x"') + expect(r.kind).toBe("block") + expect(r.kind === "block" && r.reason.includes("wip")).toBe(true) + }) + test("incomplete with extra files still blocks", () => { + makePlan("extras-wip", false, ["context.md", "notes.md"]) + expect(bash("git push origin main").kind).toBe("block") + }) + test("plan.md without checklist.md skipped", () => { + const dir = join(tmp, "docs", "plan", "no-checklist") + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "plan.md"), "- [ ] step") + expect(bash('gh pr create --title "x"').kind).toBe("allow") + }) +}) diff --git a/hooks/__tests__/plan-scanner.test.ts b/hooks/__tests__/plan-scanner.test.ts new file mode 100644 index 0000000..05f6560 --- /dev/null +++ b/hooks/__tests__/plan-scanner.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { + findActivePlans, + findIncompletePlanMdPaths, + findPrimaryActivePlan, + hasActivePlan, +} from "../core/lib/plan-scanner" + +let tmp: string + +function makePlan(name: string, opts: { plan?: string; checklist?: boolean } = {}) { + const dir = join(tmp, "docs", "plan", name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "plan.md"), opts.plan ?? "- [ ] step 1") + if (opts.checklist !== false) { + writeFileSync(join(dir, "checklist.md"), "# checklist") + } +} + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "plan-scanner-")) +}) + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) + +describe("plan-scanner", () => { + test("returns nothing when docs/plan does not exist", () => { + expect(findActivePlans(tmp)).toEqual([]) + expect(hasActivePlan(tmp)).toBe(false) + }) + + test("ignores plans missing checklist.md", () => { + makePlan("incomplete-only", { checklist: false }) + expect(findActivePlans(tmp)).toEqual([]) + }) + + test("ignores plans whose plan.md has no unchecked items", () => { + makePlan("done", { plan: "- [x] done" }) + expect(findActivePlans(tmp)).toEqual([]) + }) + + test("finds active plans with both files and unchecked items", () => { + makePlan("active") + const plans = findActivePlans(tmp) + expect(plans.length).toBe(1) + expect(plans[0].name).toBe("active") + expect(plans[0].planMdPath.endsWith("plan.md")).toBe(true) + expect(hasActivePlan(tmp)).toBe(true) + expect(findPrimaryActivePlan(tmp)?.name).toBe("active") + }) + + test("findIncompletePlanMdPaths returns all incomplete paths", () => { + makePlan("a") + makePlan("b") + makePlan("done", { plan: "- [x] done" }) + const paths = findIncompletePlanMdPaths(tmp) + expect(paths.length).toBe(2) + expect(paths.every((p) => p.endsWith("plan.md"))).toBe(true) + }) +}) diff --git a/hooks/__tests__/plan-update-reminder.test.ts b/hooks/__tests__/plan-update-reminder.test.ts new file mode 100644 index 0000000..599f1ee --- /dev/null +++ b/hooks/__tests__/plan-update-reminder.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { + check, + handleTaskCreate, + handleTaskUpdate, + isExemptTask, +} from "../core/plan-update-reminder" + +let tmp: string + +function makePlan(name: string, plan = "- [ ] step 1") { + const dir = join(tmp, "docs", "plan", name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "plan.md"), plan) + writeFileSync(join(dir, "checklist.md"), "# checklist") +} + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "plan-update-")) +}) + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) + +describe("isExemptTask", () => { + test("doc-related subject", () => expect(isExemptTask({ subject: "문서 정리" })).toBe(true)) + test("hook keyword", () => expect(isExemptTask({ subject: "hook 추가" })).toBe(true)) + test("yml file mention", () => + expect(isExemptTask({ description: "application.yml 수정" })).toBe(true)) + test("normal feature task is not exempt", () => + expect(isExemptTask({ subject: "회원 가입 API 구현" })).toBe(false)) +}) + +describe("handleTaskCreate", () => { + test("reminds when no active plan", () => { + const msg = handleTaskCreate({ subject: "새 기능" }, tmp) + expect(msg).not.toBeNull() + expect(msg!.includes("새 기능")).toBe(true) + expect(msg!.includes("plan.md")).toBe(true) + }) + test("silent when active plan exists", () => { + makePlan("existing") + expect(handleTaskCreate({ subject: "추가 작업" }, tmp)).toBeNull() + }) + test("silent for exempt task even without plan", () => { + expect(handleTaskCreate({ subject: "문서 정리" }, tmp)).toBeNull() + }) +}) + +describe("handleTaskUpdate", () => { + test("reminds on completed with active plan", () => { + makePlan("feature-x") + const msg = handleTaskUpdate({ taskId: "1", status: "completed" }, tmp) + expect(msg).not.toBeNull() + expect(msg!.includes("plan.md")).toBe(true) + expect(msg!.includes("[x]")).toBe(true) + }) + test("silent on in_progress", () => { + makePlan("feature-x") + expect(handleTaskUpdate({ taskId: "1", status: "in_progress" }, tmp)).toBeNull() + }) + test("silent when no active plan", () => { + expect(handleTaskUpdate({ taskId: "1", status: "completed" }, tmp)).toBeNull() + }) + test("silent when all plans done", () => { + makePlan("done", "- [x] done") + expect(handleTaskUpdate({ taskId: "1", status: "completed" }, tmp)).toBeNull() + }) +}) + +describe("check", () => { + test("TaskCreate without plan returns block", () => { + const r = check({ tool: "TaskCreate", args: { subject: "X" }, cwd: tmp }) + expect(r.kind).toBe("block") + }) + test("TaskUpdate completed with plan returns block", () => { + makePlan("p") + const r = check({ tool: "TaskUpdate", args: { status: "completed" }, cwd: tmp }) + expect(r.kind).toBe("block") + }) + test("Other tools allowed", () => { + expect(check({ tool: "Edit", args: { file_path: "/x" }, cwd: tmp }).kind).toBe("allow") + }) +}) diff --git a/hooks/__tests__/work-plan-enforcer.test.ts b/hooks/__tests__/work-plan-enforcer.test.ts new file mode 100644 index 0000000..3e5fae1 --- /dev/null +++ b/hooks/__tests__/work-plan-enforcer.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { check } from "../core/work-plan-enforcer" + +let tmp: string + +function makeActivePlan() { + const dir = join(tmp, "docs", "plan", "active") + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "plan.md"), "- [ ] step") + writeFileSync(join(dir, "checklist.md"), "# c") +} + +function edit(filePath: string) { + return check({ tool: "Edit", args: { file_path: filePath }, cwd: tmp }) +} + +function bash(command: string) { + return check({ tool: "Bash", args: { command }, cwd: tmp }) +} + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "wpe-")) +}) + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) + +describe("Edit/Write blocking", () => { + test("src kotlin without plan blocks", () => { + expect(edit(`${tmp}/src/main/kotlin/Task.kt`).kind).toBe("block") + }) + test("src kotlin Write tool blocks", () => { + expect(check({ tool: "Write", args: { file_path: `${tmp}/src/main/kotlin/X.kt` }, cwd: tmp }).kind).toBe( + "block", + ) + }) + test("docs path allowed", () => { + expect(edit(`${tmp}/docs/architecture.md`).kind).toBe("allow") + }) + test(".claude path allowed", () => { + expect(edit(`${tmp}/.claude/hooks/x.py`).kind).toBe("allow") + }) + test(".opencode path allowed", () => { + expect(edit(`${tmp}/.opencode/plugins/x.js`).kind).toBe("allow") + }) + test("build.gradle.kts allowed", () => { + expect(edit(`${tmp}/build.gradle.kts`).kind).toBe("allow") + }) + test("application.yml allowed", () => { + expect(edit(`${tmp}/src/main/resources/application.yml`).kind).toBe("allow") + }) + test("CLAUDE.md allowed", () => { + expect(edit(`${tmp}/CLAUDE.md`).kind).toBe("allow") + }) + test("README.md allowed", () => { + expect(edit(`${tmp}/README.md`).kind).toBe("allow") + }) + test("backslash src path blocked", () => { + expect(edit(`${tmp}\\src\\main\\kotlin\\Task.kt`).kind).toBe("block") + }) + test("backslash docs path allowed", () => { + expect(edit(`${tmp}\\docs\\architecture.md`).kind).toBe("allow") + }) + + test("active plan unblocks src", () => { + makeActivePlan() + expect(edit(`${tmp}/src/main/kotlin/Task.kt`).kind).toBe("allow") + }) + + test("plan.md only (no checklist) still blocks", () => { + const dir = join(tmp, "docs", "plan", "incomplete") + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "plan.md"), "- [ ] step") + expect(edit(`${tmp}/src/main/kotlin/Task.kt`).kind).toBe("block") + }) + + test("all plans done blocks", () => { + const dir = join(tmp, "docs", "plan", "done") + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "plan.md"), "- [x] done") + writeFileSync(join(dir, "checklist.md"), "# c") + expect(edit(`${tmp}/src/main/kotlin/Task.kt`).kind).toBe("block") + }) +}) + +describe("Bash mutation blocking", () => { + test("echo > src blocks", () => { + expect(bash("echo hello > src/main/kotlin/Task.kt").kind).toBe("block") + }) + test("cat >> src blocks", () => { + expect(bash("cat data.txt >> src/main/kotlin/Task.kt").kind).toBe("block") + }) + test("sed -i src blocks", () => { + expect(bash("sed -i 's/old/new/g' src/main/kotlin/Task.kt").kind).toBe("block") + }) + test("tee src blocks", () => { + expect(bash("tee src/main/kotlin/Task.kt").kind).toBe("block") + }) + test("cp -> src blocks", () => { + expect(bash("cp template.kt src/main/kotlin/Task.kt").kind).toBe("block") + }) + test("mv -> src blocks", () => { + expect(bash("mv old.kt src/main/kotlin/Task.kt").kind).toBe("block") + }) + test("rm src blocks", () => { + expect(bash("rm src/main/kotlin/Task.kt").kind).toBe("block") + }) + + test("cat src (read-only) allows", () => { + expect(bash("cat src/main/kotlin/Task.kt").kind).toBe("allow") + }) + test("grep src allows", () => { + expect(bash("grep -r 'class' src/").kind).toBe("allow") + }) + test("ls src allows", () => { + expect(bash("ls src/main/kotlin/").kind).toBe("allow") + }) + test("gradlew build allows", () => { + expect(bash("./gradlew build").kind).toBe("allow") + }) + test("echo > docs allows", () => { + expect(bash("echo hello > docs/output.md").kind).toBe("allow") + }) + test("echo > application.yml allows", () => { + expect(bash("echo hello > src/main/resources/application.yml").kind).toBe("allow") + }) + + test("active plan unblocks src writes", () => { + makeActivePlan() + expect(bash("echo hello > src/main/kotlin/Task.kt").kind).toBe("allow") + }) +}) + +describe("edge cases", () => { + test("empty file_path passes", () => { + expect(edit("").kind).toBe("allow") + }) + test("missing file_path passes", () => { + expect(check({ tool: "Edit", args: {}, cwd: tmp }).kind).toBe("allow") + }) + test("empty command passes", () => { + expect(bash("").kind).toBe("allow") + }) + test("Read tool always allows", () => { + expect(check({ tool: "Read", args: { file_path: "src/main/kotlin/Task.kt" }, cwd: tmp }).kind).toBe( + "allow", + ) + }) +}) diff --git a/hooks/adapters/claude/run.mjs b/hooks/adapters/claude/run.mjs new file mode 100755 index 0000000..bad0202 --- /dev/null +++ b/hooks/adapters/claude/run.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env bun +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const HERE = dirname(fileURLToPath(import.meta.url)) +const CORE_DIR = resolve(HERE, "..", "..", "core") + +const CORES = { + "block-dangerous": "block-dangerous.ts", + "work-plan-enforcer": "work-plan-enforcer.ts", + "plan-completion-guard": "plan-completion-guard.ts", + "plan-update-reminder": "plan-update-reminder.ts", + "layer-doc-reminder": "layer-doc-reminder.ts", +} + +async function readStdin() { + let data = "" + for await (const chunk of process.stdin) data += chunk + return data +} + +async function main() { + const hookName = process.argv[2] + if (!hookName || !CORES[hookName]) { + process.stderr.write(`unknown hook: ${hookName}\n`) + process.exit(0) + } + + let payload + try { + payload = JSON.parse((await readStdin()) || "{}") + } catch { + process.exit(0) + } + + const cwd = + payload.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd() + + const ctx = { + tool: payload.tool_name ?? "", + args: payload.tool_input ?? {}, + cwd, + sessionId: payload.session_id, + } + + const mod = await import(resolve(CORE_DIR, CORES[hookName])) + const result = await mod.check(ctx) + + switch (result.kind) { + case "block": + process.stderr.write(result.reason) + process.exit(2) + case "context": + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: payload.hook_event_name, + additionalContext: result.message, + }, + }), + ) + process.exit(0) + case "modify": + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: payload.hook_event_name, + updatedInput: result.updatedInput, + }, + }), + ) + process.exit(0) + case "allow": + default: + process.exit(0) + } +} + +main().catch((err) => { + process.stderr.write(`hook dispatcher error: ${err?.stack ?? err}\n`) + process.exit(0) +}) diff --git a/hooks/core/block-dangerous.ts b/hooks/core/block-dangerous.ts new file mode 100644 index 0000000..5bed777 --- /dev/null +++ b/hooks/core/block-dangerous.ts @@ -0,0 +1,41 @@ +import type { HookCtx, HookModule, HookResult } from "./types" + +const BLOCKED_PATTERNS: Array<[RegExp, string]> = [ + [/git\s+reset\s+--hard/i, "git reset --hard는 커밋되지 않은 작업을 삭제합니다"], + [/git\s+push\s+.*--force/i, "git push --force는 원격 히스토리를 덮어씁니다"], + [/git\s+push\s+.*-f\b/i, "git push -f는 원격 히스토리를 덮어씁니다"], + [/git\s+clean\s+-.*f/i, "git clean -f는 추적되지 않은 파일을 영구 삭제합니다"], + [/git\s+checkout\s+\.\s*$/i, "git checkout .은 모든 커밋되지 않은 변경사항을 삭제합니다"], + [/git\s+restore\s+\.\s*$/i, "git restore .은 모든 커밋되지 않은 변경사항을 삭제합니다"], + [/git\s+stash\s+drop/i, "git stash drop은 스태시된 변경사항을 영구 삭제합니다"], + [/git\s+stash\s+clear/i, "git stash clear는 모든 스태시를 삭제합니다"], + [/\brm\s+-rf\s+\/\s*(;|&&|\|\||$)/i, "rm -rf /는 매우 위험합니다"], + [/\brm\s+-rf\s+~/i, "rm -rf ~는 홈 디렉토리를 삭제합니다"], + [/\brm\s+-rf\s+\.\./i, "rm -rf ..은 상위 디렉토리를 삭제할 수 있습니다"], + [/\brm\s+-rf\s+\*/i, "rm -rf *는 위험합니다"], + [/DROP\s+DATABASE/i, "DROP DATABASE는 파괴적입니다"], + [/DROP\s+TABLE/i, "DROP TABLE은 파괴적입니다"], + [/TRUNCATE\s+TABLE/i, "TRUNCATE TABLE은 모든 데이터를 삭제합니다"], +] + +export function check(ctx: HookCtx): HookResult { + if (ctx.tool !== "Bash" && ctx.tool !== "bash") return { kind: "allow" } + + const command = (ctx.args?.command as string) ?? "" + if (!command) return { kind: "allow" } + + for (const [pattern, reason] of BLOCKED_PATTERNS) { + if (pattern.test(command)) { + return { + kind: "block", + reason: `🚫 [Safety Hook] 차단됨: ${reason}\n\n시도된 명령어: ${command}\n정말 실행이 필요하다면, 터미널에서 직접 실행하세요.`, + } + } + } + return { kind: "allow" } +} + +export const module: HookModule = { + events: ["PreToolUse"] as const, + check, +} diff --git a/hooks/core/layer-doc-reminder.ts b/hooks/core/layer-doc-reminder.ts new file mode 100644 index 0000000..07c7832 --- /dev/null +++ b/hooks/core/layer-doc-reminder.ts @@ -0,0 +1,101 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" + +import type { HookCtx, HookResult } from "./types" + +const LAYER_DOCS: Record = { + domain: "docs/layers/domain.md", + application: "docs/layers/application.md", + infrastructure: "docs/layers/infrastructure.md", + presentation: "docs/layers/presentation.md", +} + +const SECURITY_DOC = "docs/spring-security-7.md" + +let reminderDir = join(tmpdir(), "loop_layer_reminders") + +const inProcessReminded = new Set() + +export function detectLayer(filePath: string): string | null { + for (const layer of Object.keys(LAYER_DOCS)) { + if (new RegExp(`(^|/)${layer}(/|$)`).test(filePath)) return layer + } + return null +} + +export function isSecurityRelated(filePath: string): boolean { + return ( + /(^|\/)common\/config(\/|$)/.test(filePath) && + filePath.toLowerCase().includes("security") + ) +} + +function isDocsPath(filePath: string): boolean { + return /(^|\/)docs\//.test(filePath) +} + +function isTestPath(filePath: string): boolean { + return /(^|\/)src\/test\//.test(filePath) || filePath.endsWith("Test.kt") +} + +function markerPath(sessionId: string, key: string): string { + return join(reminderDir, `${sessionId}_${key}`) +} + +function shouldRemind(sessionId: string | undefined, key: string): boolean { + if (sessionId) return !existsSync(markerPath(sessionId, key)) + return !inProcessReminded.has(key) +} + +function markReminded(sessionId: string | undefined, key: string): void { + if (sessionId) { + mkdirSync(reminderDir, { recursive: true }) + writeFileSync(markerPath(sessionId, key), "") + return + } + inProcessReminded.add(key) +} + +function collectFilePaths(args: Record): string[] { + const direct = (args.file_path ?? args.filePath ?? args.path) as string | undefined + return direct ? [direct] : [] +} + +export function check(ctx: HookCtx): HookResult { + if (!["Edit", "Write", "edit", "write"].includes(ctx.tool)) return { kind: "allow" } + + const paths = collectFilePaths(ctx.args) + if (paths.length === 0) return { kind: "allow" } + + const messages: string[] = [] + + for (const filePath of paths) { + if (!filePath) continue + if (isDocsPath(filePath)) continue + if (isTestPath(filePath)) continue + + const layer = detectLayer(filePath) + if (layer && shouldRemind(ctx.sessionId, layer)) { + markReminded(ctx.sessionId, layer) + messages.push(`[${layer}] 이 레이어 첫 수정입니다. 먼저 ${LAYER_DOCS[layer]} 를 읽으세요.`) + } + + if (isSecurityRelated(filePath) && shouldRemind(ctx.sessionId, "security")) { + markReminded(ctx.sessionId, "security") + messages.push(`[security] Security 설정 첫 수정입니다. 먼저 ${SECURITY_DOC} 를 읽으세요.`) + } + } + + if (messages.length > 0) return { kind: "block", reason: messages.join("\n") } + return { kind: "allow" } +} + +export const __testing = { + setReminderDir(dir: string) { + reminderDir = dir + }, + resetInProcess() { + inProcessReminded.clear() + }, +} diff --git a/hooks/core/lib/path.ts b/hooks/core/lib/path.ts new file mode 100644 index 0000000..b06cbfc --- /dev/null +++ b/hooks/core/lib/path.ts @@ -0,0 +1,45 @@ +export const EXEMPT_PATTERNS = [ + "/docs/", + "/.claude/", + "/.opencode/", + "README", + "CLAUDE.md", +] as const + +export const EXEMPT_EXTENSIONS = [ + ".gradle.kts", + ".gradle", + ".yml", + ".yaml", + ".properties", + ".toml", + ".xml", +] as const + +export function normalizePath(filePath: string): string { + return filePath.replace(/\\/g, "/") +} + +export function isExempt(filePath: string): boolean { + const normalized = normalizePath(filePath) + for (const pattern of EXEMPT_PATTERNS) { + if (normalized.includes(pattern)) return true + } + if (normalized.startsWith("docs/")) return true + if (normalized.startsWith(".claude/")) return true + if (normalized.startsWith(".opencode/")) return true + for (const ext of EXEMPT_EXTENSIONS) { + if (normalized.endsWith(ext)) return true + } + return false +} + +export function isSrcPath(filePath: string): boolean { + return /(^|\/)src\//.test(normalizePath(filePath)) +} + +export function shouldBlockSrcPath(filePath: string): boolean { + const normalized = normalizePath(filePath) + if (isExempt(normalized)) return false + return isSrcPath(normalized) +} diff --git a/hooks/core/lib/plan-scanner.ts b/hooks/core/lib/plan-scanner.ts new file mode 100644 index 0000000..86cf4cf --- /dev/null +++ b/hooks/core/lib/plan-scanner.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs" +import { join, resolve } from "node:path" + +export const REQUIRED_PLAN_FILES = ["plan.md", "checklist.md"] as const + +export type ActivePlan = { + name: string + planMdPath: string + updatedAtMs: number +} + +function planBaseDir(directory: string): string { + return resolve(directory, "docs", "plan") +} + +function hasUncheckedItems(content: string): boolean { + return content.includes("- [ ]") +} + +function hasRequiredPlanFiles(planDir: string): boolean { + return REQUIRED_PLAN_FILES.every((file) => existsSync(join(planDir, file))) +} + +function latestPlanTimestamp(planDir: string): number { + let latest = 0 + for (const file of REQUIRED_PLAN_FILES) { + const filePath = join(planDir, file) + if (!existsSync(filePath)) continue + const mtime = statSync(filePath).mtimeMs + if (mtime > latest) latest = mtime + } + return latest +} + +function readPlanDirEntries(directory: string): string[] { + const planBase = planBaseDir(directory) + if (!existsSync(planBase)) return [] + try { + return readdirSync(planBase, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + } catch { + return [] + } +} + +export function findActivePlans(directory: string): ActivePlan[] { + const entries = readPlanDirEntries(directory) + const planBase = planBaseDir(directory) + const active: ActivePlan[] = [] + + for (const name of entries) { + const planDir = join(planBase, name) + if (!hasRequiredPlanFiles(planDir)) continue + const planMdPath = join(planDir, "plan.md") + const content = readFileSync(planMdPath, "utf-8") + if (!hasUncheckedItems(content)) continue + active.push({ + name, + planMdPath, + updatedAtMs: latestPlanTimestamp(planDir), + }) + } + + active.sort((a, b) => b.updatedAtMs - a.updatedAtMs) + return active +} + +export function findPrimaryActivePlan(directory: string): ActivePlan | null { + const active = findActivePlans(directory) + return active.length > 0 ? active[0] : null +} + +export function hasActivePlan(directory: string): boolean { + return findPrimaryActivePlan(directory) !== null +} + +export function findIncompletePlanMdPaths(directory: string): string[] { + return findActivePlans(directory).map((p) => p.planMdPath) +} diff --git a/hooks/core/plan-completion-guard.ts b/hooks/core/plan-completion-guard.ts new file mode 100644 index 0000000..debee8c --- /dev/null +++ b/hooks/core/plan-completion-guard.ts @@ -0,0 +1,22 @@ +import { findIncompletePlanMdPaths } from "./lib/plan-scanner" +import type { HookCtx, HookResult } from "./types" + +const PR_CREATE_RE = /\bgh\s+pr\s+create\b/ +const GIT_PUSH_RE = /\bgit\s+push\b/ + +export function check(ctx: HookCtx): HookResult { + if (ctx.tool !== "Bash" && ctx.tool !== "bash") return { kind: "allow" } + const command = (ctx.args?.command as string) ?? "" + if (!PR_CREATE_RE.test(command) && !GIT_PUSH_RE.test(command)) return { kind: "allow" } + + const incomplete = findIncompletePlanMdPaths(ctx.cwd) + if (incomplete.length === 0) return { kind: "allow" } + + const list = incomplete.map((p) => ` - ${p}`).join("\n") + return { + kind: "block", + reason: + `[plan-guard] 미완료 plan.md가 있어 PR 생성/push를 차단합니다.\n` + + `다음 plan.md의 모든 항목을 완료([x])하거나 불필요한 계획을 정리하세요:\n${list}`, + } +} diff --git a/hooks/core/plan-update-reminder.ts b/hooks/core/plan-update-reminder.ts new file mode 100644 index 0000000..cb8b5b7 --- /dev/null +++ b/hooks/core/plan-update-reminder.ts @@ -0,0 +1,72 @@ +import { findActivePlans } from "./lib/plan-scanner" +import type { HookCtx, HookResult } from "./types" + +const EXEMPT_FILE_PATTERNS = [ + "/docs/", + "docs/", + "/.claude/", + ".claude/", + "/.opencode/", + ".opencode/", + "README", + "CLAUDE.md", + ".gradle.kts", + ".gradle", + ".yml", + ".yaml", + ".properties", + ".toml", + ".xml", + "Dockerfile", + "docker-compose", + ".github/workflows", +] as const + +const EXEMPT_KEYWORD_RE = /문서|설정\s*파일|빌드|CI\/?CD|배포|deploy|hook|훅/i + +function textOfTask(args: Record): string { + const subject = (args.subject as string) ?? "" + const description = (args.description as string) ?? "" + const content = (args.content as string) ?? "" + return `${subject} ${description} ${content}` +} + +export function isExemptTask(args: Record): boolean { + const text = textOfTask(args) + for (const pattern of EXEMPT_FILE_PATTERNS) { + if (text.includes(pattern)) return true + } + if (EXEMPT_KEYWORD_RE.test(text)) return true + return false +} + +export function handleTaskCreate(args: Record, cwd: string): string | null { + if (isExemptTask(args)) return null + const subject = (args.subject as string) ?? (args.content as string) ?? "" + const active = findActivePlans(cwd) + if (active.length === 0) { + return ( + `[plan] TaskCreate 감지: "${subject}"\n` + + `docs/plan/{작업명}/ 에 plan.md, checklist.md 를 생성했는지 확인하세요. ` + + `(docs/work-planning-rules.md 참고)` + ) + } + return null +} + +export function handleTaskUpdate(args: Record, cwd: string): string | null { + if (isExemptTask(args)) return null + if (args.status !== "completed") return null + const active = findActivePlans(cwd) + if (active.length === 0) return null + const list = active.map((p) => p.planMdPath).join(", ") + return `[plan] Task 완료 감지. 다음 plan.md 의 해당 단계를 [x]로 업데이트하세요: ${list}` +} + +export function check(ctx: HookCtx): HookResult { + let message: string | null = null + if (ctx.tool === "TaskCreate") message = handleTaskCreate(ctx.args, ctx.cwd) + else if (ctx.tool === "TaskUpdate") message = handleTaskUpdate(ctx.args, ctx.cwd) + if (message) return { kind: "block", reason: message } + return { kind: "allow" } +} diff --git a/hooks/core/types.ts b/hooks/core/types.ts new file mode 100644 index 0000000..324edf1 --- /dev/null +++ b/hooks/core/types.ts @@ -0,0 +1,17 @@ +export type HookCtx = { + tool: string + args: Record + cwd: string + sessionId?: string +} + +export type HookResult = + | { kind: "allow" } + | { kind: "block"; reason: string } + | { kind: "context"; message: string } + | { kind: "modify"; updatedInput: Record } + +export type HookModule = { + events: ReadonlyArray<"PreToolUse" | "PostToolUse"> + check: (ctx: HookCtx) => HookResult | Promise +} diff --git a/hooks/core/work-plan-enforcer.ts b/hooks/core/work-plan-enforcer.ts new file mode 100644 index 0000000..97c4288 --- /dev/null +++ b/hooks/core/work-plan-enforcer.ts @@ -0,0 +1,89 @@ +import { hasActivePlan } from "./lib/plan-scanner" +import { normalizePath, shouldBlockSrcPath } from "./lib/path" +import type { HookCtx, HookResult } from "./types" + +const BASH_WRITE_PATTERNS: RegExp[] = [ + /\b(touch|mkdir|cp|mv|rm|install|truncate|dd)\b/i, + /\b(sed|perl)\b[\s\S]*\s-i\b/i, + /\btee\b/i, + /\b(cat|echo|printf)\b[\s\S]*>{1,2}/i, + />{1,2}\s*["']?[^\s"']*src\//i, + /\bpython(?:3)?\b[\s\S]*open\(\s*["'][^"']*src\/[^"']*["']\s*,\s*["'][wa]/i, +] + +function isBashSourceMutation(command: string): boolean { + if (!/(^|\W)src\//.test(command)) return false + return BASH_WRITE_PATTERNS.some((p) => p.test(command)) +} + +function extractSrcPathCandidates(command: string): string[] { + const paths: string[] = [] + const regex = /(?:^|[\s"'])([^\s"'`]*src\/[^\s"'`]+)/g + let m: RegExpExecArray | null + while ((m = regex.exec(command)) !== null) { + paths.push(m[1].replace(/[;|,&]+$/g, "")) + } + return paths +} + +function extractPathsFromPatchText(patchText: string): string[] { + const paths: string[] = [] + const regex = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm + let m: RegExpExecArray | null + while ((m = regex.exec(patchText)) !== null) { + paths.push(m[1].trim()) + } + return paths +} + +function collectFilePaths(args: Record, tool: string): string[] { + const direct = (args.file_path ?? args.filePath ?? args.path) as string | undefined + const paths: string[] = [] + if (direct) paths.push(direct) + if ((tool === "apply_patch") && typeof args.patchText === "string") { + paths.push(...extractPathsFromPatchText(args.patchText as string)) + } + return paths +} + +function blockResult(): HookResult { + return { + kind: "block", + reason: + "[work-plan] 소스 코드 수정이 차단되었습니다. " + + "docs/plan/{작업명}/ 에 plan.md, checklist.md 를 먼저 생성하세요. " + + "(docs/work-planning-rules.md 참고)", + } +} + +export function check(ctx: HookCtx): HookResult { + const tool = ctx.tool + + if (tool === "Bash" || tool === "bash") { + const command = (ctx.args?.command as string) ?? "" + if (!command) return { kind: "allow" } + if (!isBashSourceMutation(command)) return { kind: "allow" } + const candidates = extractSrcPathCandidates(command) + const hasNonExempt = candidates.some((p) => shouldBlockSrcPath(p)) + if (!hasNonExempt) return { kind: "allow" } + if (hasActivePlan(ctx.cwd)) return { kind: "allow" } + return blockResult() + } + + if (!["Edit", "Write", "edit", "write", "apply_patch"].includes(tool)) { + return { kind: "allow" } + } + + const paths = collectFilePaths(ctx.args, tool) + if (paths.length === 0) return { kind: "allow" } + + for (const filePath of paths) { + if (!filePath) continue + const normalized = normalizePath(filePath) + if (!shouldBlockSrcPath(normalized)) continue + if (hasActivePlan(ctx.cwd)) return { kind: "allow" } + return blockResult() + } + + return { kind: "allow" } +} diff --git a/hooks/package.json b/hooks/package.json new file mode 100644 index 0000000..6e94bf5 --- /dev/null +++ b/hooks/package.json @@ -0,0 +1,13 @@ +{ + "name": "loop-hooks", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.5.0" + } +} diff --git a/hooks/tsconfig.json b/hooks/tsconfig.json new file mode 100644 index 0000000..c4a54dc --- /dev/null +++ b/hooks/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "isolatedModules": true, + "types": ["bun-types"] + }, + "include": ["core/**/*.ts", "adapters/**/*.ts", "__tests__/**/*.ts"] +}