Files

397 lines
16 KiB
Python

#!/usr/bin/env python3
"""wiki_rules.py — claim_gate / structure_lint 공유 기계장치 + SSOT 데이터 (stdlib only).
여기엔 *정책*이 아니라 *공유 메커니즘*과 *참조 데이터*만 둔다:
- 이벤트/IO 헬퍼 (wiki_claim_gate.py 에서 verbatim 이관, 두 훅이 공유)
- CLAIM_REQUIREMENTS : claim 테이블/섹션 요구 SSOT
(이전엔 claim_gate inline 하드코딩 — 감사 G5 dedup 대상)
- 심각도 티어 상수 : structure_lint 의 게이트 결정(차단 vs fix-up vs warn)이 소비
정책(block/warn 적용)은 각 훅에 남는다.
"""
from __future__ import annotations
import json
import re
import shlex
import sys
from pathlib import Path
# 두 훅과 동일하게 이 스크립트 위치 기준으로 repo 루트 해석 (.claude/hooks/<this> -> root).
ROOT = Path(__file__).resolve().parents[2]
# ---------- 이벤트/IO 헬퍼 (wiki_claim_gate.py 에서 verbatim 이관) ----------
def read_event() -> dict:
try:
raw = sys.stdin.read()
return json.loads(raw) if raw.strip() else {}
except Exception:
return {}
def tool_name(event: dict) -> str:
if isinstance(event.get("tool_name"), str):
return event["tool_name"]
tc = event.get("tool_call") or event.get("toolCall") or {}
if isinstance(tc, dict):
return tc.get("name") or tc.get("tool_name") or ""
return ""
def tool_input(event: dict) -> dict:
if isinstance(event.get("tool_input"), dict):
return event["tool_input"]
tc = event.get("tool_call") or event.get("toolCall") or {}
if not isinstance(tc, dict):
return {}
for key in ("input", "arguments", "args"):
value = tc.get(key)
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
return {}
def target_path(inp: dict) -> Path | None:
for key in ("file_path", "path", "absolute_path", "TargetFile", "target_file"):
value = inp.get(key)
if isinstance(value, str) and value:
p = Path(value)
return p if p.is_absolute() else ROOT / p
return None
def write_content(inp: dict) -> str:
for key in ("content", "CodeContent", "CodeEdit", "text"):
value = inp.get(key)
if isinstance(value, str):
return value
value = inp.get("new_string") or inp.get("newString")
return value if isinstance(value, str) else ""
def projected_content(path: Path | None, inp: dict) -> str:
"""Return the file content after a Write/Edit/MultiEdit-style operation.
Claude Code Edit inputs often contain only old_string/new_string. If we
inspect the snippet alone, legitimate migrations get blocked because the
snippet does not include every required section. This function checks the
projected final file instead whenever enough information is available.
"""
full = write_content(inp)
if path is None:
return full
# Write-style calls usually provide full content.
if isinstance(inp.get("content"), str) or isinstance(inp.get("CodeContent"), str):
return full
try:
current = path.read_text(encoding="utf-8") if path.exists() else ""
except Exception:
current = ""
old = inp.get("old_string") or inp.get("oldString")
new = inp.get("new_string") or inp.get("newString")
if isinstance(old, str) and isinstance(new, str) and old in current:
return current.replace(old, new, 1)
edits = inp.get("edits")
if isinstance(edits, list):
projected = current
for edit in edits:
if not isinstance(edit, dict):
continue
old = edit.get("old_string") or edit.get("oldString")
new = edit.get("new_string") or edit.get("newString")
if isinstance(old, str) and isinstance(new, str) and old in projected:
projected = projected.replace(old, new, 1)
return projected
return full or current
def command_string(inp: dict) -> str:
for key in ("command", "cmd", "CommandLine", "Command", "args"):
value = inp.get(key)
if isinstance(value, str):
return value
if isinstance(value, list):
return " ".join(shlex.quote(str(x)) for x in value)
return ""
def rel_to_root(path: Path | None) -> str:
if path is None:
return ""
try:
return str(path.resolve().relative_to(ROOT.resolve()))
except Exception:
return ""
def has_table(text: str, section: str, columns: list[str]) -> bool:
if section not in text:
return False
start = text.find(section)
next_section = text.find("\n## ", start + len(section))
body = text[start: next_section if next_section != -1 else len(text)]
return all(col in body for col in columns)
# ---------- claim 요구 SSOT (감사 G5 dedup 대상) ----------
# claim_gate 의 table/section 요구를 *데이터*로 표현. 정책(block) 은 claim_gate 에 남는다.
# prefix 는 tuple — str.startswith(tuple) 로 매칭.
CLAIM_REQUIREMENTS = [
{"prefix": ("raw/official-docs/", "raw/company-tech-blogs/"),
"tables": [("## Claims Extracted",
["Claim ID", "Claim", "Evidence quote", "Strength", "Applies to", "Does not prove"])],
"sections": ["## Usage Boundaries"]},
{"prefix": ("raw/branch-notes/",),
"tables": [("## Decision Evidence Map",
["Decision ID", "Decision", "Supporting Claims", "Evidence Strength", "Open Risk"])],
"section_regex": [r"^## .*\bClaims To Verify\b"]},
{"prefix": ("wiki/concepts/",),
"tables": [("## Claim-backed Knowledge",
["Knowledge Point", "Supporting Claims", "Confidence", "Notes"])]},
# 투자 조사 노트 — 실제 돈 결정의 증거층. 환각된 금융 claim 이 근거표/출처/verbatim
# 없이 들어오는 걸 쓰기 시점에 차단 (Spec F V1). invest 명령은 Claude 전용이나
# hook 은 경로 기반이라 3-플랫폼 모두 적용.
{"prefix": ("raw/invest-research/",),
"tables": [("## Claims Extracted",
["Claim ID", "Claim", "Evidence quote", "Strength", "적용 조건", "증명 못 하는 것"])],
"sections": ["## 출처 / Sources", "## 핵심 인용"]},
# ---- 2026-06-10 하네스 감사 P1-7 확장 (RC4 경로 공백 해소) ----
# wiki/projects 실무 적용 문서 — canonical 의 절반이자 파생(interview/portfolio)이
# 인용하는 층. 증거 등급 구조(실제 구현 내용 + Sources) 쓰기 시점 강제.
# named-hub(wiki/projects/<slug>.md + 형제 폴더 <slug>/)는 claim_gate 정책에서 면제.
{"prefix": ("wiki/projects/",),
"sections": ["## 실제 구현 내용", "## Sources"]},
# 파생 산출물 — canonical 경유 강제 (CLAUDE.md §11·§15 최대 금지의 결정론 backstop).
# content_regex: [pattern, message] 쌍 — 본문 전체에 1회 이상 매칭 필요.
{"prefix": ("wiki/interview/", "wiki/blog/", "wiki/explainer/"),
"sections": ["## Sources"],
"content_regex": [
[r"\[\[wiki/(concepts|projects)/",
"파생 산출물은 `## Sources` 에 canonical wikilink(`[[wiki/concepts/...]]` 또는 "
"`[[wiki/projects/...]]`) ≥1 필수 (CLAUDE.md §15 — canonical 경유 강제)."]]},
{"prefix": ("wiki/portfolio/",),
"sections": ["## Sources"],
"content_regex": [
[r"\[\[wiki/projects/",
"portfolio 는 `[[wiki/projects/...]]` 링크 필수 (CLAUDE.md §15 — projects 중심 파생)."]]},
# invest-daily — 실돈 경로의 최대 환각 위험면. 섹션 강제 + 수치행 출처/조사시점
# 정책은 claim_gate 의 invest_daily_numeric_failures 가 담당.
{"prefix": ("raw/invest-daily/",),
"sections": ["## 고정 체크리스트", "## 출처 / Sources"]},
# invest-ledger — 실돈 사실 기록. 4섹션 구조 강제 (행 스키마·근거 실존·산술은
# invest_ledger_check.py CLI 가 담당 — P2-17).
{"prefix": ("raw/invest-ledger/",),
"sections": ["## 현재 포지션", "## 거래 내역", "## 규칙 위반 이력", "## 손익 요약"]},
]
# 파생 산출물 status 게이트 (claim_gate 소비): Sources 의 canonical 링크가 전부
# 이 status 여야 파생 가능 (CLAUDE.md §15). explainer 는 status 면제(개인 이해용).
CANONICAL_OK_STATUS = frozenset({"reviewed", "verified", "published-ready"})
DERIVED_STATUS_PREFIXES = ("wiki/interview/", "wiki/blog/", "wiki/portfolio/")
# ---------- 심각도 티어 (structure_lint 소비) ----------
# 항상-틀린(ghost) 검사 → PreToolUse 차단.
CRITICAL_CODES = frozenset({"BROKEN_LINK", "BROKEN_MD_LINK"})
# 완성 선언 문서에서만 의미 있는 완성도 검사 → PostToolUse exit-2 fix-up.
FIXUP_CODES = frozenset({
"MISSING_SECTION", "MISSING_FRONTMATTER", "EMPTY_SELECTION_CRITERION",
"DANGLING_ANCHOR", "PROJECT_NO_DIAGRAM", "PROJECT_NO_BRANCH_TABLE",
"UNMAPPED_SOURCE_TYPE",
})
# ---------- 위키 에이전트 레지스트리 (SubagentStop 스코핑 SSOT) ----------
# .claude/agents/*.md 의 name: 과 1:1. SubagentStop 출력 계약(COMPLETE trap /
# wiki-verdict / wiki-stats)은 이 에이전트들의 출력에만 적용한다 — 범용 subagent
# (Explore/Plan/general-purpose 등)가 'Verdict: COMPLETE' 류 문구나 인용된 예시
# 블록 때문에 오차단되는 것을 방지 (하네스 감사 P0-1, 실측 재현 2026-06-10:
# docs/superpowers/specs/2026-06-10-claude-harness-audit-report.md §1).
WIKI_AGENT_TYPES = frozenset({
"branch-depth-auditor",
"coverage-auditor",
"extraction-broker",
"project-readiness-auditor",
"wiki-adversarial-reviewer",
"wiki-consistency-auditor",
"wiki-decision-researcher",
"wiki-diagram-reviewer",
"wiki-doc-author",
"wiki-link-verifier",
"wiki-research-lane",
"wiki-source-summarizer",
})
# ---------- judge verdict 스키마 + quorum tally (Spec B) ----------
# 정책 아님 — *기계장치*. judge 출력의 기계 파싱 가능한 wiki-verdict 블록을 검증/집계.
VERDICT_FENCE_RE = re.compile(r"```wiki-verdict\s*\n(.*?)\n```", re.S)
VALID_VERDICT = {"ready", "not-ready", "blocked"}
VALID_ACTION = {"KEEP", "DOWNGRADE", "REJECT"}
REFUTATIONS_REQUIRED = 2 # ≥2 REJECT → kill (deep-research 기본값)
def parse_verdict_block(text):
"""본문에서 wiki-verdict fenced 블록을 찾아 dict 로 파싱. 없으면 None."""
m = VERDICT_FENCE_RE.search(text or "")
if not m:
return None
out = {"agent": None, "kv": {}, "findings": []}
for line in m.group(1).splitlines():
line = line.strip()
if not line:
continue
fm = re.match(r"finding:\s*(\S+)\s+action:\s*(\S+)", line)
if fm:
out["findings"].append((fm.group(1), fm.group(2)))
continue
kv = re.match(r"([a-z_]+):\s*(.+)$", line)
if kv:
k, v = kv.group(1), kv.group(2).strip()
if k == "agent":
out["agent"] = v
else:
out["kv"][k] = v
return out
def validate_verdict_block(text):
"""(parsed, errors). parsed None → 마커 없음(judge 아님, caller 통과).
errors 비어있지 않으면 스키마 위반 → SubagentStop 차단."""
parsed = parse_verdict_block(text)
if parsed is None:
return None, []
errors = []
if not parsed["agent"]:
errors.append("wiki-verdict 블록에 `agent:` 누락")
if parsed["agent"] == "wiki-adversarial-reviewer":
if not parsed["findings"]:
errors.append("adversarial verdict 블록에 `finding: <id> action: <act>` 행 ≥1 필요")
for fid, act in parsed["findings"]:
if act not in VALID_ACTION:
errors.append(f"finding {fid}: action '{act}' 비허용(KEEP|DOWNGRADE|REJECT)")
else:
v = parsed["kv"].get("verdict")
if v not in VALID_VERDICT:
errors.append(f"verdict '{v}' 비허용(ready|not-ready|blocked)")
blocking = None
try:
blocking = int(parsed["kv"].get("blocking", ""))
int(parsed["kv"].get("should_fix", ""))
int(parsed["kv"].get("advisory", ""))
except ValueError:
errors.append("blocking/should_fix/advisory 는 정수여야 함")
if blocking is not None and v == "ready" and blocking != 0:
errors.append("verdict=ready 인데 blocking≠0 (모순)")
if blocking is not None and v == "not-ready" and blocking < 1:
errors.append("verdict=not-ready 인데 blocking<1 (모순)")
return parsed, errors
def tally_quorum(block_texts, refutations_required=REFUTATIONS_REQUIRED):
"""N개 adversarial verdict 블록 → per-finding 결정론 판정.
refute = DOWNGRADE 또는 REJECT (원 severity 반박).
default-refute: 어떤 pass 가 finding 을 누락/malformed → abstain(non-KEEP).
결정: reject≥req → KILL · (reject+downgrade)≥req → DOWNGRADE ·
keep≥req → KEEP · 그 외(정족수 미달) → UNVERIFIED(통과 금지).
"""
parsed_all = [parse_verdict_block(t) for t in block_texts]
all_fids = set()
for p in parsed_all:
if p:
for fid, _ in p["findings"]:
all_fids.add(fid)
per = {}
for fid in all_fids:
keep = downgrade = reject = abstain = 0
for p in parsed_all:
act = None
if p:
for f, a in p["findings"]:
if f == fid:
act = a
break
if act == "KEEP":
keep += 1
elif act == "DOWNGRADE":
downgrade += 1
elif act == "REJECT":
reject += 1
else:
abstain += 1
if reject >= refutations_required:
decision = "KILL"
elif (reject + downgrade) >= refutations_required:
decision = "DOWNGRADE"
elif keep >= refutations_required:
decision = "KEEP"
else:
decision = "UNVERIFIED"
per[fid] = {"keep": keep, "downgrade": downgrade, "reject": reject,
"abstain": abstain, "n": len(block_texts), "decision": decision}
return per
# ---------- funnel stats 블록 (Spec C, no-silent-truncation) ----------
STATS_FENCE_RE = re.compile(r"```wiki-stats\s*\n(.*?)\n```", re.S)
def parse_stats_block(text):
"""본문에서 wiki-stats fenced 블록을 찾아 dict 로 파싱. 없으면 None."""
m = STATS_FENCE_RE.search(text or "")
if not m:
return None
out = {"agent": None, "kv": {}}
for line in m.group(1).splitlines():
line = line.strip()
if not line:
continue
kv = re.match(r"([a-z_]+):\s*(.+)$", line)
if kv:
k, v = kv.group(1), kv.group(2).strip()
if k == "agent":
out["agent"] = v
else:
out["kv"][k] = v
return out
def validate_stats_block(text):
"""(parsed, errors). parsed None → 마커 없음(통과). errors → SubagentStop 차단.
funnel 균형(found=processed+dropped) + dropped>0 시 dropped_reason 필수 (no-silent-truncation)."""
parsed = parse_stats_block(text)
if parsed is None:
return None, []
errors = []
if not parsed["agent"]:
errors.append("wiki-stats 블록에 `agent:` 누락")
nums = {}
for k in ("found", "processed", "dropped"):
try:
nums[k] = int(parsed["kv"].get(k, ""))
except ValueError:
errors.append(f"wiki-stats `{k}` 는 정수여야 함 (funnel 필수 필드)")
if len(nums) == 3:
if nums["found"] != nums["processed"] + nums["dropped"]:
errors.append(
f"funnel 불균형: found({nums['found']}) ≠ processed({nums['processed']}) "
f"+ dropped({nums['dropped']}) — 조용한 누락 의심"
)
if nums["dropped"] > 0 and not parsed["kv"].get("dropped_reason", "").strip():
errors.append("dropped>0 인데 `dropped_reason` 누락 (no-silent-truncation 위반)")
return parsed, errors