diff --git a/.claude/hooks/plan_completion_guard.py b/.claude/hooks/plan_completion_guard.py index ada5f55..4b7fdcf 100644 --- a/.claude/hooks/plan_completion_guard.py +++ b/.claude/hooks/plan_completion_guard.py @@ -20,7 +20,7 @@ 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", "context.md", "checklist.md"] +REQUIRED_PLAN_FILES = ["plan.md", "checklist.md"] # gh pr create 또는 git push 감지 PR_CREATE_RE = re.compile(r"\bgh\s+pr\s+create\b") diff --git a/.claude/hooks/test_plan_completion_guard.py b/.claude/hooks/test_plan_completion_guard.py index 06f9bd5..7ca7d55 100644 --- a/.claude/hooks/test_plan_completion_guard.py +++ b/.claude/hooks/test_plan_completion_guard.py @@ -25,17 +25,18 @@ def run_hook(tool_name, tool_input, plan_base=None): return result.returncode, result.stderr -def create_plan_dir(base, name, complete=True): - """테스트용 plan 디렉토리 생성.""" +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, "context.md"), "w") as f: - f.write("# Context\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(): @@ -97,6 +98,46 @@ def test_mixed_plans_blocked(): 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, @@ -106,6 +147,9 @@ def test_mixed_plans_blocked(): 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: diff --git a/.claude/settings.json b/.claude/settings.json index 4f2d59a..44a424a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,8 +1,6 @@ { "permissions": { - "allow": [ - "Bash(rm -rf *)" - ] + "allow": [] }, "hooks": { "PreToolUse": [ diff --git a/docs/plan/#44-plan-guard-policy-fix/checklist.md b/docs/plan/#44-plan-guard-policy-fix/checklist.md new file mode 100644 index 0000000..be9456e --- /dev/null +++ b/docs/plan/#44-plan-guard-policy-fix/checklist.md @@ -0,0 +1,13 @@ +# plan_completion_guard 정책 정합성 회복 및 위험 권한 제거 검증 체크리스트 + +## 필수 항목 +- [x] `plan_completion_guard.py`가 `plan.md` + `checklist.md`만 필수로 요구 +- [x] 정책 문서(`docs/work-planning-rules.md`)와 가드의 필수 문서 목록이 일치 +- [x] 회귀 테스트 추가: `context.md` 없이 미완료 plan이 차단되는지 확인하는 케이스 존재 +- [x] `test_plan_completion_guard.py` 모든 테스트 통과 (10/10) +- [x] `test_work_plan_enforcer.py` 회귀 없음 (34/34) +- [x] `.claude/settings.json`에서 와일드카드 삭제 allow 제거됨 (allow: []) +- [x] `block_dangerous.py`가 여전히 보조 방어선으로 동작 (PreToolUse Bash 매처 유지) + +## 선택 항목 (해당 시) +- [x] 가드 메시지가 사용자에게 명확한 안내 제공 (기존 메시지 유지) diff --git a/docs/plan/#44-plan-guard-policy-fix/plan.md b/docs/plan/#44-plan-guard-policy-fix/plan.md new file mode 100644 index 0000000..2858c1b --- /dev/null +++ b/docs/plan/#44-plan-guard-policy-fix/plan.md @@ -0,0 +1,17 @@ +# plan_completion_guard 정책 정합성 회복 및 위험 권한 제거 계획 + +> Issue: #44 + +## 단계 + +- [x] 1단계: 회귀 재현 — 2종 문서(plan.md + checklist.md)만 있는 미완료 plan에서 가드가 거짓통과(exit=0)하는 것을 임시 디렉토리로 재현 +- [x] 2단계: `plan_completion_guard.py`의 `REQUIRED_PLAN_FILES`에서 `context.md` 제거 +- [x] 3단계: `test_plan_completion_guard.py`의 `create_plan_dir` 헬퍼를 2종 구조로 갱신 (extra_files 인자로 부수 파일 추가 지원) +- [x] 4단계: 회귀 방지 테스트 3건 추가 — 2종만 있는 미완료 차단, extra 파일 있어도 차단, 필수 2종 미충족 디렉토리는 가드 대상 외 +- [x] 5단계: `.claude/settings.json`의 와일드카드 삭제 allow 항목 제거 (allow 빈 배열로) +- [x] 6단계: 가드 테스트 실행 — `test_plan_completion_guard.py` 10/10, `test_work_plan_enforcer.py` 34/34 통과 +- [x] 7단계: 검증 체크리스트 확인 후 커밋/PR 생성 + +## 비고 + +- `test_block_dangerous.py`(pytest 기반) 12건 실패는 본 변경 이전부터 존재한 결함(soft-warn 케이스가 hard-block으로 동작). 별도 이슈로 분리 예정.