feat: 공식 문서 근거자료, 브랜치 기능 문서 작성
This commit is contained in:
Executable
+370
@@ -0,0 +1,370 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user