#!/usr/bin/env python3 """Claude Code hook for LLM Wiki claim traceability. This hook is intentionally narrow. It does not try to judge whether a claim is true; it blocks writes that bypass the repository's required evidence structure: - raw source notes must extract source claims. - branch notes must map decisions to supporting claims. - wiki concept notes must keep claim-backed knowledge separate from inference. - report-like outputs must not claim completion while missing those artifacts. 공유 메커니즘(이벤트 파싱/projected_content)과 claim 요구 SSOT(CLAIM_REQUIREMENTS)는 wiki_rules.py 로 이관됨(감사 G5 dedup). 정책(block 적용)만 본 파일에 남는다. """ from __future__ import annotations import json import re import sys from pathlib import Path # 공유 메커니즘/데이터는 wiki_rules 로 이관. sibling import 가 스크립트 실행/spec 로드 # 양쪽에서 해석되도록 이 파일 디렉터리를 sys.path 에 추가. sys.path.insert(0, str(Path(__file__).resolve().parent)) import wiki_rules from wiki_rules import ( read_event, tool_name, tool_input, target_path, projected_content, command_string, rel_to_root, has_table, ) # Antigravity hook 은 exit-code 가 아니라 {decision} JSON(exit 0)을 기대 # (geminicli.com/docs/hooks/reference). 검사 로직은 동일, 출력 봉투만 분기. # 플래그로 명시 활성 — Claude/Codex 는 기존 exit-code 규약 그대로. ANTIGRAVITY = "--antigravity" in sys.argv # Claude main agent 의 Stop 이벤트 전용 모드 (P1-9). Antigravity native `Stop` 은 # subagent 의미라 subagent_stop_gate 로 가지만, Claude 의 Stop 은 *메인 에이전트* # 최종 메시지 — COMPLETE trap/wiki-verdict 를 적용하면 하네스 자체를 논의하는 # 메타 대화가 오차단된다. 따라서 main-stop 은 fenced wiki-stats funnel 만 검증 # (명령 최종 보고의 no-silent-truncation backstop). MAIN_STOP = "--main-stop" in sys.argv def emit_allow(extra: dict | None = None) -> None: if ANTIGRAVITY: # Antigravity/Gemini: strict {decision} JSON, exit 0, fail-open. print(json.dumps({"decision": "allow"})) sys.exit(0) # Claude Code hooks: allow = exit 0 with no stdout. Structured JSON is only # valid for specific hook events such as SubagentStart additionalContext. if extra: print(json.dumps(extra, ensure_ascii=False)) sys.exit(0) def emit_block(reason: str) -> None: if ANTIGRAVITY: # Antigravity deny: {decision:deny, reason} on stdout, exit 0. print(json.dumps({"decision": "deny", "reason": reason}, ensure_ascii=False)) sys.exit(0) # Claude Code blocking convention: write reason to stderr and exit 2. # Returning Antigravity/Gemini-style JSON from PreToolUse causes # "Hook JSON output validation failed — Invalid input". print(reason, file=sys.stderr) sys.exit(2) def _is_named_hub(rel: str, root: Path) -> bool: """named-hub folder-note (/.md + 형제 폴더 /, linking-rules §12) 는 MOC 구조 문서 — claim 구조 요구 면제 (structure lint classify 와 동일 판정).""" parts = rel.split("/") if len(parts) != 3 or parts[0] not in ("raw", "wiki") or not parts[2].endswith(".md"): return False return (root / parts[0] / parts[1] / parts[2][:-3]).is_dir() def _section_body(text: str, header_prefix: str) -> str: """header_prefix 로 시작하는 ## 섹션의 본문 (다음 ## 까지). 없으면 ''.""" i = text.find(header_prefix) if i == -1: return "" j = text.find("\n## ", i + len(header_prefix)) return text[i: j if j != -1 else len(text)] def derived_source_status_failures(rel: str, text: str, root: Path) -> list[str]: """파생 산출물(P1-8) status 게이트: ## Sources 의 canonical 링크가 전부 status ∈ CANONICAL_OK_STATUS 여야 함 (CLAUDE.md §15). explainer 는 면제. 링크 부재는 content_regex 가, 깨진 타깃은 structure_lint --pre 가 잡으므로 여기선 skip.""" if not rel.startswith(wiki_rules.DERIVED_STATUS_PREFIXES): return [] body = _section_body(text, "## Sources") targets = [] for m in re.finditer(r"\[\[([^\]]+)\]\]", body): t = m.group(1).replace("\\|", "|").split("|")[0].split("#")[0].strip() if t.endswith(".md"): t = t[:-3] if t.startswith(("wiki/concepts/", "wiki/projects/")): targets.append(t) bad = [] for t in sorted(set(targets)): try: head = (root / (t + ".md")).read_text(encoding="utf-8") except OSError: continue # 타깃 부재 → BROKEN_LINK 는 structure lint 몫 status = "" if head.startswith("---"): end = head.find("\n---", 3) m = re.search(r"^status:\s*(\S+)", head[: end if end != -1 else len(head)], re.M) status = m.group(1).strip() if m else "" if status not in wiki_rules.CANONICAL_OK_STATUS: bad.append(f"`[[{t}]]` (status: {status or '없음'})") if bad: return [ "파생 산출물 원천 status 게이트 (CLAUDE.md §15): `## Sources` 의 canonical 문서는 " "모두 status ∈ {reviewed, verified, published-ready} 여야 함. 미달: " + ", ".join(bad) ] return [] def invest_daily_numeric_failures(text: str) -> list[str]: """invest-daily 고정 체크리스트: 값이 있는 행은 출처·조사시점 필수 (수치 환각 차단). 빈 값 행은 허용 (템플릿: '모르면 비우되 추측 금지').""" body = _section_body(text, "## 고정 체크리스트") if not body: return [] lines = body.splitlines() header_idx = val_i = src_i = time_i = None for i, line in enumerate(lines): if "|" in line and "출처" in line and ("값" in line or "수치" in line): cols = [c.strip() for c in line.strip().strip("|").split("|")] for k, c in enumerate(cols): if "값" in c or "수치" in c: val_i = k elif "출처" in c: src_i = k elif "시점" in c: time_i = k header_idx = i break if header_idx is None or val_i is None or src_i is None: return [] fails: list[str] = [] j = header_idx + 2 # 헤더 + 구분선 다음부터 데이터 행 while j < len(lines) and lines[j].lstrip().startswith("|"): cells = [c.strip() for c in lines[j].strip().strip("|").split("|")] val = cells[val_i] if val_i < len(cells) else "" src = cells[src_i] if src_i < len(cells) else "" tim = cells[time_i] if (time_i is not None and time_i < len(cells)) else "" label = cells[0] if cells else "?" if val and not re.fullmatch(r"<[^>]*>", val): if not src: fails.append(f"고정 체크리스트 '{label}' 행: 값이 있는데 출처 비어있음 (수치마다 출처+조사시점 필수)") elif time_i is not None and not tim: fails.append(f"고정 체크리스트 '{label}' 행: 값이 있는데 조사시점 비어있음") j += 1 return fails def check_markdown_write(rel: str, text: str, root: Path | None = None) -> list[str]: """raw/wiki 문서 쓰기의 claim 구조 게이트. 테이블/섹션 요구는 wiki_rules.CLAIM_REQUIREMENTS(SSOT 데이터)에서 도출하고, 의미 규칙(officially-supported 강도, 감사리포트 COMPLETE traceability, 파생 status 게이트, invest-daily 수치행 출처)은 정책이므로 본 함수에 남긴다. """ root = root or wiki_rules.ROOT failures: list[str] = [] if not rel.endswith(".md") or not text: return failures if _is_named_hub(rel, root): return failures # named-hub MOC — claim 구조 요구 면제 for req in wiki_rules.CLAIM_REQUIREMENTS: if not rel.startswith(req["prefix"]): # str.startswith 는 tuple 허용 continue for section, cols in req.get("tables", []): if not has_table(text, section, cols): failures.append( f"{req['prefix'][0]} 류 문서는 `{section}` 표(열: {' | '.join(cols)})를 가져야 한다." ) for sec in req.get("sections", []): if sec not in text: failures.append(f"문서는 `{sec}` 섹션을 가져야 한다.") for rx in req.get("section_regex", []): if not re.search(rx, text, re.MULTILINE): failures.append( "branch-note must include `## Claims To Verify` " "(bilingual `## 검증해야 할 주장 / Claims To Verify` 도 허용)." ) for rx, msg in req.get("content_regex", []): if not re.search(rx, text, re.MULTILINE): failures.append(msg) # 의미 규칙 (P1-8): 파생 산출물 원천 status 게이트. failures += derived_source_status_failures(rel, text, root) # 의미 규칙 (P1-7): invest-daily 수치행 출처/조사시점. if rel.startswith("raw/invest-daily/"): failures += invest_daily_numeric_failures(text) # 의미 규칙 1: branch-note 의 'officially supported' 주장은 official 강도 필요 (정책 — 인라인). if rel.startswith("raw/branch-notes/"): if re.search(r"(?i)\bofficial(?:ly)? supported\b|공식(?:적으로)?\s*지원", text): if not re.search(r"official-(standard|vendor-doc|reference)", text): failures.append( "`officially supported` style claim requires an official claim strength " "(`official-standard`, `official-vendor-doc`, or `official-reference`)." ) # 의미 규칙 2: 감사 리포트가 COMPLETE 주장 시 claim traceability 검증 포함 (정책 — 인라인). if rel.startswith("docs/superpowers/specs/") and rel.endswith("-report.md"): if re.search(r"Verdict:\s*COMPLETE|\*\*Verdict:?\*\*\s*COMPLETE", text): required = ["Decision Evidence Map", "Claims Extracted", "UNSUPPORTED_DECISION"] missing = [item for item in required if item not in text] if missing: failures.append( "audit report cannot claim COMPLETE unless it verifies claim traceability. " f"Missing references: {', '.join(missing)}." ) return failures def command_writes_wiki_docs(command: str) -> bool: if not command: return False doc_path = r"(raw/|wiki/|docs/superpowers/specs/|\.claude/)" if not re.search(doc_path, command): return False # Shell redirection is write only when followed by a non-space target. if re.search(r"(?:^|\s)(?:>|>>)\s*[^&\s]", command): return True write_signal = ( r"(\btee\b|\bcp\b|\bmv\b|\btouch\b|\btruncate\b|" r"\bsed\s+-i\b|\bperl\s+-pi\b|\bcat\s+<<|" r"write_text\s*\(|write_bytes\s*\(|\.write\s*\(|fs\.writeFile|" r"open\s*\([^)]*,\s*['\"][wax]['\"]|Path\s*\([^)]*\)\.write_)" ) return bool(re.search(write_signal, command)) def subagent_context(event: dict) -> None: context = ( "LLM Wiki claim traceability is mandatory. For raw official-doc/company-tech-blog notes, " "extract `## Claims Extracted` rows with Claim IDs and Usage Boundaries. For branch-notes, " "write `## Decision Evidence Map` and map every Decision ID to Supporting Claims. " "Do not call company tech-blog evidence an official best practice unless corroborated by " "official-standard, official-vendor-doc, or official-reference claims. If evidence is absent, " "label it UNSUPPORTED_DECISION instead of presenting it as fact." ) # SubagentStart supports context injection via hookSpecificOutput. print(json.dumps({ "hookSpecificOutput": { "hookEventName": "SubagentStart", "additionalContext": context, } }, ensure_ascii=False)) sys.exit(0) def subagent_stop_gate(event: dict) -> None: # agent_type 스코핑 (P0-1): 위키 출력 계약은 위키 에이전트(WIKI_AGENT_TYPES)에만 # 적용한다. 범용 subagent(Explore/general-purpose 등)는 'Verdict: COMPLETE' 한 마디 # 또는 보고서에 인용한 예시 블록만으로 차단되어 본래 임무에서 이탈한 재시도 출력을 # 내는 오차단이 실측 재현됨(감사 보고 §1). agent_type 부재(Gemini AfterAgent 등 # 타 플랫폼 이벤트)는 기존 보수적 검증을 유지한다. agent_type = event.get("agent_type") if isinstance(agent_type, str) and agent_type and agent_type not in wiki_rules.WIKI_AGENT_TYPES: emit_allow() # Claude: last_assistant_message. Gemini/Antigravity AfterAgent: prompt_response # ("The final text generated by the agent"). message = event.get("last_assistant_message") or event.get("prompt_response") or "" if not isinstance(message, str): emit_allow() # 감사/리뷰 *리포트* 완료 주장(Verdict: COMPLETE)에만 traceability 를 요구한다. # bare `DONE`/`완료` 는 worker(예: wiki-source-summarizer `**Status:** DONE`)의 성공 # 표기이며 branch-traceability(Decision Evidence Map 등)와 무관 — 요구하면 정상 worker 가 # 잘못 차단된다(외부 리뷰 Finding 1). check_markdown_write(:88) 와 동일 패턴으로 정렬. # P2-22 (사용자 승인 2026-06-10): stop_hook_active 무검증 통과(one-retry) 폐지. # P0-1 agent_type 스코핑 + 출력 계약 정비로 오차단 원인이 제거됐으므로, 위키 # 에이전트의 스키마 위반은 재시도에도 계속 차단한다. 무한루프 없음 — Claude Code # 가 연속 8회 차단 시 강제 통과시킴 (main_stop_gate 는 one-retry 유지 — 대화 보호). if re.search(r"Verdict:\s*COMPLETE|\*\*Verdict:?\*\*\s*COMPLETE", message): missing = [] for term in ("Claim ID", "Decision Evidence Map", "UNSUPPORTED_DECISION"): if term not in message: missing.append(term) if missing: emit_block( "Subagent output claims completion but does not report claim-traceability checks: " + ", ".join(missing) ) # judge 출력에 wiki-verdict 마커가 있으면 스키마 검증(없으면 judge 아님 → 통과). parsed, verr = wiki_rules.validate_verdict_block(message) if parsed is not None and verr: emit_block("judge verdict 블록 스키마 오류:\n- " + "\n- ".join(verr)) # wiki-stats 마커가 있으면 funnel 검증(균형·dropped_reason). 없으면 통과. sparsed, serr = wiki_rules.validate_stats_block(message) if sparsed is not None and serr: emit_block("wiki-stats 블록 오류:\n- " + "\n- ".join(serr)) emit_allow() def main_stop_gate(event: dict) -> None: """Claude main agent Stop (P1-9): fenced wiki-stats 만 검증. 필드 부재 fail-open.""" message = event.get("last_assistant_message") or "" if not isinstance(message, str) or event.get("stop_hook_active"): emit_allow() sparsed, serr = wiki_rules.validate_stats_block(message) if sparsed is not None and serr: emit_block("wiki-stats 블록 오류 (main agent 최종 보고):\n- " + "\n- ".join(serr)) emit_allow() def main() -> None: event = read_event() hook_event = event.get("hook_event_name") or "" if hook_event == "SubagentStart": subagent_context(event) # Claude main agent Stop (--main-stop 플래그로 명시) — stats-only 게이트. if hook_event == "Stop" and MAIN_STOP: main_stop_gate(event) # Claude: SubagentStop. Antigravity native: Stop. Gemini CLI: AfterAgent # (the variant that exposes prompt_response for content inspection). if hook_event in ("SubagentStop", "Stop", "AfterAgent"): subagent_stop_gate(event) name = tool_name(event) inp = tool_input(event) if name == "Bash" or "bash" in name.lower() or "command" in name.lower(): cmd = command_string(inp) if command_writes_wiki_docs(cmd): emit_block( "Direct shell/script writes to wiki docs are blocked. Use Claude Code Write/Edit/MultiEdit " "so claim-traceability gates can inspect the target content." ) emit_allow() path = target_path(inp) rel = rel_to_root(path) # Only inspect write-like tools. Read/Skill/Glob/Grep/List must never be # blocked just because the existing file is not migrated yet. write_like_name = name in {"Write", "Edit", "MultiEdit", "NotebookEdit"} or any( token in name.lower() for token in ["write", "edit", "multiedit", "notebookedit"] ) write_like_input = any(k in inp for k in [ "content", "CodeContent", "CodeEdit", "new_string", "newString", "edits", "text" ]) if not (write_like_name or write_like_input): emit_allow() text = projected_content(path, inp) if not rel: emit_allow() failures = check_markdown_write(rel, text) if failures: emit_block("LLM Wiki Claim Gate failed for `" + rel + "`:\n- " + "\n- ".join(failures)) emit_allow() if __name__ == "__main__": main()