371 lines
17 KiB
Python
Executable File
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()
|