Files

371 lines
17 KiB
Python
Executable File

#!/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 (<cat>/<slug>.md + 형제 폴더 <slug>/, 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()