397 lines
16 KiB
Python
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
|