Files
llm-wiki/docs/superpowers/plans/2026-06-06-spec-a-deterministic-backbone-gate.md

683 lines
30 KiB
Markdown

# Spec A — Deterministic Backbone Gate Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Commits excluded** per user instruction ("커밋은 제외") — no `git commit` steps. Each task ends with a test-green checkpoint instead.
**Goal:** Turn the existing (non-blocking) `wiki_structure_lint.py` into a real hybrid gate — PreToolUse blocks ghost-creating links, PostToolUse becomes an exit-2 fix-up gate for completeness findings on completion-declared docs — while deduping the claim-rule SSOT into a shared `wiki_rules.py` and adding the first tests for the blocking `wiki_claim_gate.py`.
**Architecture:** New `wiki_rules.py` holds shared event/IO mechanism + the claim-requirement SSOT data + severity-tier constants. `wiki_claim_gate.py` imports it and drives its table checks from the shared data (behavior-preserving refactor). `wiki_structure_lint.py` gains a `--pre` mode (PreToolUse, blocks `BROKEN_LINK`/`BROKEN_MD_LINK` on projected content) and a tiered `--hook` mode (PostToolUse exit-2 fix-up for `FIXUP_CODES` when the doc declares completion). Link logic stays in structure_lint; claim logic stays in claim_gate; only mechanism + reference data is shared.
**Tech Stack:** Python 3 stdlib only (`unittest`, `importlib`, `re`, `pathlib`, `json`). No third-party deps. Claude Code hooks (`settings.json`).
**Spec:** `docs/superpowers/specs/2026-06-06-spec-a-deterministic-backbone-gate-design.md`
---
## File Structure
- **Create** `.claude/hooks/wiki_rules.py` — shared mechanism (moved verbatim from claim_gate) + `CLAIM_REQUIREMENTS` SSOT data + `CRITICAL_CODES`/`FIXUP_CODES`.
- **Create** `.claude/hooks/test_wiki_rules.py` — unit tests for moved `projected_content` + data integrity.
- **Modify** `.claude/hooks/wiki_claim_gate.py``import wiki_rules`; drive `check_markdown_write` table checks from `CLAIM_REQUIREMENTS`; keep two semantic special-cases inline.
- **Create** `.claude/hooks/test_wiki_claim_gate.py` — regression-lock the 5-prefix block/pass behavior.
- **Modify** `.claude/hooks/wiki_structure_lint.py``import wiki_rules`; add `run_pre()` + `run_hook()` testable functions; wire `--pre`; tier `--hook`; add suppressed-count line.
- **Modify** `.claude/hooks/test_wiki_structure_lint.py` — add `--pre`/`--hook` fix-up cases.
- **Modify** `.claude/settings.json` — add `structure_lint.py --pre` to PreToolUse.
**Sibling-import rule (applies to both hooks):** the first executable lines (after `from __future__`) must be:
```python
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import wiki_rules
```
This makes `import wiki_rules` resolve whether the file is run as a script (hook) or loaded via `importlib.spec_from_file_location` (tests).
---
## Task 1: Create `wiki_rules.py` shared module
**Files:**
- Create: `.claude/hooks/wiki_rules.py`
- Test: `.claude/hooks/test_wiki_rules.py`
The moved helpers are **verbatim copies** of `wiki_claim_gate.py` current functions: `read_event` (42-47), `tool_name` (50-56), `tool_input` (59-76), `target_path` (79-85), `write_content` (88-94), `projected_content` (97-135), `command_string` (138-145), `rel_to_root` (148-154), `has_table` (157-163). `ROOT` is the same `Path(__file__).resolve().parents[2]`.
- [ ] **Step 1: Write the failing test**
Create `.claude/hooks/test_wiki_rules.py`:
```python
#!/usr/bin/env python3
"""wiki_rules.py 단위 테스트 (stdlib unittest)."""
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
_SPEC = importlib.util.spec_from_file_location(
"wiki_rules", str(Path(__file__).with_name("wiki_rules.py")))
wr = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(wr)
class TestProjectedContent(unittest.TestCase):
def test_write_full_content(self):
# Write 스타일: content 키가 있으면 그대로 반환
inp = {"content": "FULL BODY"}
self.assertEqual(wr.projected_content(None, inp), "FULL BODY")
def test_edit_applies_old_new(self):
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "f.md"
p.write_text("alpha BETA gamma")
inp = {"old_string": "BETA", "new_string": "DELTA"}
self.assertEqual(wr.projected_content(p, inp), "alpha DELTA gamma")
class TestSeverityData(unittest.TestCase):
def test_critical_codes_are_links(self):
self.assertIn("BROKEN_LINK", wr.CRITICAL_CODES)
self.assertIn("BROKEN_MD_LINK", wr.CRITICAL_CODES)
self.assertNotIn("MISSING_SECTION", wr.CRITICAL_CODES)
def test_fixup_codes_are_completeness(self):
for c in ("MISSING_SECTION", "MISSING_FRONTMATTER",
"EMPTY_SELECTION_CRITERION", "DANGLING_ANCHOR",
"PROJECT_NO_DIAGRAM", "PROJECT_NO_BRANCH_TABLE",
"UNMAPPED_SOURCE_TYPE"):
self.assertIn(c, wr.FIXUP_CODES)
self.assertNotIn("BROKEN_LINK", wr.FIXUP_CODES)
def test_claim_requirements_cover_five_prefixes(self):
prefixes = {p for req in wr.CLAIM_REQUIREMENTS for p in req["prefix"]}
for p in ("raw/official-docs/", "raw/company-tech-blogs/",
"raw/branch-notes/", "wiki/concepts/"):
self.assertIn(p, prefixes)
if __name__ == "__main__":
unittest.main(verbosity=2)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 .claude/hooks/test_wiki_rules.py`
Expected: FAIL — `FileNotFoundError`/`ModuleNotFoundError` (wiki_rules.py does not exist yet).
- [ ] **Step 3: Write minimal implementation**
Create `.claude/hooks/wiki_rules.py`. Copy the 9 helper functions **verbatim** from `wiki_claim_gate.py` (current line ranges noted in the Task header), then append the SSOT data and severity constants:
```python
#!/usr/bin/env python3
"""wiki_rules.py — claim_gate / structure_lint 공유 기계장치 + SSOT 데이터 (stdlib only).
여기엔 *정책*이 아니라 *공유 메커니즘*과 *참조 데이터*만 둔다:
- 이벤트/IO 헬퍼 (claim_gate 에서 이관, 두 훅이 공유)
- 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 shlex
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
# ---------- 이벤트/IO 헬퍼 (claim_gate 에서 verbatim 이관) ----------
# read_event / tool_name / tool_input / target_path / write_content /
# projected_content / command_string / rel_to_root / has_table
# (wiki_claim_gate.py 의 동일 함수 본문을 그대로 복사. target_path/rel_to_root 는 ROOT 사용.)
# ... (verbatim copies here) ...
# ---------- claim 요구 SSOT (G5 dedup 대상) ----------
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"])]},
]
# ---------- 심각도 티어 (structure_lint 소비) ----------
CRITICAL_CODES = frozenset({"BROKEN_LINK", "BROKEN_MD_LINK"}) # PreToolUse block
FIXUP_CODES = frozenset({
"MISSING_SECTION", "MISSING_FRONTMATTER", "EMPTY_SELECTION_CRITERION",
"DANGLING_ANCHOR", "PROJECT_NO_DIAGRAM", "PROJECT_NO_BRANCH_TABLE",
"UNMAPPED_SOURCE_TYPE",
}) # PostToolUse exit-2 (완성 선언 시)
```
Replace the `# ... (verbatim copies here) ...` comment with the 9 functions copied exactly from `wiki_claim_gate.py`. Keep their bodies unchanged except `target_path`/`rel_to_root`, which already reference module-level `ROOT` — that now resolves to `wiki_rules.ROOT` (same value).
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 .claude/hooks/test_wiki_rules.py`
Expected: PASS (5 tests OK).
- [ ] **Step 5: Checkpoint (no commit)**
Run: `python3 .claude/hooks/test_wiki_rules.py` → all green. Do NOT commit.
---
## Task 2: Refactor `wiki_claim_gate.py` to consume `wiki_rules` (behavior-preserving)
**Files:**
- Modify: `.claude/hooks/wiki_claim_gate.py` (replace lines 42-163 helpers with import; rewrite `check_markdown_write` 166-220 to drive table/section checks from `wiki_rules.CLAIM_REQUIREMENTS`)
- Test: `.claude/hooks/test_wiki_claim_gate.py` (new — written FIRST to lock current behavior)
This is a refactor: tests are written against **current** behavior and must stay green through the change.
- [ ] **Step 1: Write the regression-lock test**
Create `.claude/hooks/test_wiki_claim_gate.py`:
```python
#!/usr/bin/env python3
"""wiki_claim_gate.py 회귀 고정 테스트 — check_markdown_write 행동 동치 (refactor 전후 동일)."""
import importlib.util
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
_SPEC = importlib.util.spec_from_file_location(
"wcg", str(Path(__file__).with_name("wiki_claim_gate.py")))
wcg = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(wcg)
CLAIMS_TABLE = (
"## Claims Extracted\n"
"| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |\n"
"|---|---|---|---|---|---|\n"
"| C1 | x | q | company-case-study | a | b |\n"
)
USAGE = "## Usage Boundaries\n- x\n"
DEM = (
"## Decision Evidence Map\n"
"| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |\n"
"|---|---|---|---|---|\n"
"| D1 | x | C1 | company-case-study | none |\n"
)
CTV = "## 검증해야 할 주장 / Claims To Verify\n- v\n"
class TestSourceNote(unittest.TestCase):
def test_missing_claims_table_blocks(self):
f = wcg.check_markdown_write("raw/official-docs/x.md", "# t\n" + USAGE)
self.assertTrue(any("Claims Extracted" in m for m in f))
def test_complete_source_note_passes(self):
f = wcg.check_markdown_write("raw/official-docs/x.md", "# t\n" + CLAIMS_TABLE + USAGE)
self.assertEqual(f, [])
class TestBranchNote(unittest.TestCase):
def test_missing_dem_blocks(self):
f = wcg.check_markdown_write("raw/branch-notes/feature-x.md", "# t\n" + CTV)
self.assertTrue(any("Decision Evidence Map" in m for m in f))
def test_complete_branch_note_passes(self):
f = wcg.check_markdown_write("raw/branch-notes/feature-x.md", "# t\n" + DEM + CTV)
self.assertEqual(f, [])
def test_officially_supported_without_strength_blocks(self):
body = "# t\n" + DEM + CTV + "\n이 기능은 officially supported 된다.\n"
f = wcg.check_markdown_write("raw/branch-notes/feature-x.md", body)
self.assertTrue(any("official" in m.lower() for m in f))
class TestConceptNote(unittest.TestCase):
def test_missing_claim_backed_blocks(self):
f = wcg.check_markdown_write("wiki/concepts/x.md", "# t\n본문")
self.assertTrue(any("Claim-backed Knowledge" in m for m in f))
class TestUnrelatedPath(unittest.TestCase):
def test_non_gated_path_passes(self):
# 게이트 대상 아닌 경로 → 통과(빈 failures)
f = wcg.check_markdown_write("wiki/projects/x.md", "# anything\n")
self.assertEqual(f, [])
if __name__ == "__main__":
unittest.main(verbosity=2)
```
- [ ] **Step 2: Run test to verify it passes against CURRENT code**
Run: `python3 .claude/hooks/test_wiki_claim_gate.py`
Expected: PASS — this locks the current behavior as the regression baseline (claim_gate already implements all these checks). If any test fails now, the test encodes a wrong expectation — fix the test to match current behavior before refactoring.
- [ ] **Step 3: Refactor `wiki_claim_gate.py`**
(a) Replace the helper block (current lines ~12-163: the `from __future__` through `has_table`) so the top reads:
```python
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import wiki_rules
from wiki_rules import (
ROOT, read_event, tool_name, tool_input, target_path, write_content,
projected_content, command_string, rel_to_root, has_table,
)
```
Keep `emit_allow`, `emit_block` as they are (they `print`/`sys.exit`, hook-specific). Delete the now-moved 9 functions from claim_gate.
(b) Rewrite `check_markdown_write(rel, text)` so the official-docs / company-tech-blogs / branch-notes / concepts table+section checks are produced by iterating `wiki_rules.CLAIM_REQUIREMENTS`, while the two **semantic** special-cases stay inline:
```python
def check_markdown_write(rel: str, text: str) -> list[str]:
failures: list[str] = []
if not rel.endswith(".md") or not text:
return failures
for req in wiki_rules.CLAIM_REQUIREMENTS:
if not rel.startswith(req["prefix"]): # tuple-of-prefixes → str.startswith accepts 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` 도 허용).")
# 의미 규칙 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 주장 시 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
```
Note: `str.startswith` accepts a tuple, so `rel.startswith(req["prefix"])` works directly with the `prefix` tuples. `main()` and the rest of the file (subagent gates, bash gate) are unchanged except they now reference the imported helpers.
- [ ] **Step 4: Run tests to verify they still pass**
Run: `python3 .claude/hooks/test_wiki_claim_gate.py`
Expected: PASS — behavior unchanged after refactor.
- [ ] **Step 5: Checkpoint (no commit)**
Run both: `python3 .claude/hooks/test_wiki_rules.py && python3 .claude/hooks/test_wiki_claim_gate.py` → green. No commit.
---
## Task 3: Add `--pre` mode to `wiki_structure_lint.py` (PreToolUse link block)
**Files:**
- Modify: `.claude/hooks/wiki_structure_lint.py` (add `import wiki_rules`; add `run_pre()`; wire `--pre` in `main`)
- Test: `.claude/hooks/test_wiki_structure_lint.py` (append `TestPreMode`)
- [ ] **Step 1: Write the failing test**
Append to `.claude/hooks/test_wiki_structure_lint.py` (the module is loaded as `wsl`; add a `sys.path` insert near the top of the file if not present, mirroring Task 1):
```python
class TestPreMode(unittest.TestCase):
def _event(self, root, rel, content):
return {"tool_name": "Write",
"tool_input": {"file_path": str(root / rel), "content": content}}
def test_ghost_wikilink_blocks(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "raw" / "branch-notes").mkdir(parents=True)
ev = self._event(root, "raw/branch-notes/b.md", "# t\nsee [[raw/nonexistent/ghost]]\n")
self.assertEqual(wsl.run_pre(ev, root), 2)
def test_backtick_placeholder_passes(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "raw" / "branch-notes").mkdir(parents=True)
ev = self._event(root, "raw/branch-notes/b.md", "# t\nfuture: `[[raw/nonexistent/ghost]]`\n")
self.assertEqual(wsl.run_pre(ev, root), 0)
def test_no_links_skips_and_passes(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "raw" / "branch-notes").mkdir(parents=True)
ev = self._event(root, "raw/branch-notes/b.md", "# t\n링크 없는 본문\n")
self.assertEqual(wsl.run_pre(ev, root), 0)
def test_non_wiki_path_passes(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "docs").mkdir()
ev = self._event(root, "docs/x.md", "see [[raw/nonexistent/ghost]]\n")
self.assertEqual(wsl.run_pre(ev, root), 0)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -k Pre`
Expected: FAIL — `AttributeError: module 'wsl' has no attribute 'run_pre'`.
- [ ] **Step 3: Implement `run_pre`**
In `wiki_structure_lint.py`, after the `from __future__ import annotations` line add the sibling import:
```python
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import wiki_rules
```
(Path is already imported below; move the `from pathlib import Path` above this insert, or use a local import — keep it stdlib-clean.) Then add, near `main()`:
```python
def run_pre(event, root):
"""PreToolUse: projected 본문의 C2 깨진링크(CRITICAL)만 차단. 반환 exit code."""
inp = wiki_rules.tool_input(event)
p = wiki_rules.target_path(inp)
if p is None or not str(p).endswith(".md"):
return 0
try:
rel = p.resolve().relative_to(root).as_posix()
except Exception:
return 0
if not (rel.startswith("raw/") or rel.startswith("wiki/")):
return 0
text = wiki_rules.projected_content(p, inp)
# 위키링크/마크다운링크가 전혀 없으면 vault 인덱스 빌드 스킵 (성능).
if "[[" not in text and "](" not in text:
return 0
vp, vb = build_vault_index(root)
doc = {"lines": text.splitlines()}
findings = check_c2(doc, vp, vb, root, {}, rel)
critical = [(c, ln, m) for (c, ln, m) in findings if c in wiki_rules.CRITICAL_CODES]
if critical:
print(f"✗ wiki-structure-lint (pre): {rel} — 깨진 링크 {len(critical)}건 → 쓰기 차단",
file=sys.stderr)
for code, ln, msg in critical[:10]:
loc = f":{ln}" if ln else ""
print(f" [{code}]{loc} {msg}", file=sys.stderr)
if len(critical) > 10:
print(f" … 외 {len(critical) - 10}건 (suppressed)", file=sys.stderr)
print(" 미존재 타깃은 백틱 코드(`[[slug]]`)로 표기하거나 타깃 파일을 먼저 생성하세요.",
file=sys.stderr)
return 2
return 0
```
Wire it in `main()` — add the arg and dispatch **before** the existing `--hook` block:
```python
ap.add_argument("--pre", action="store_true",
help="PreToolUse hook — projected 본문 C2 깨진링크 차단 (blocking)")
...
if args.pre:
import json as _json
try:
event = _json.loads(sys.stdin.read() or "{}")
except Exception:
sys.exit(0)
sys.exit(run_pre(event, root))
```
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -k Pre`
Expected: PASS (4 tests).
- [ ] **Step 5: Checkpoint (no commit)**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py` → all green (old + new).
---
## Task 4: Tier `--hook` into fix-up gate + suppressed-count line
**Files:**
- Modify: `.claude/hooks/wiki_structure_lint.py` (extract existing `--hook` body 486-531 into `run_hook(event, root)`; add exit-2 on `FIXUP_CODES` when `is_completeness_checkable`; add suppressed line)
- Test: `.claude/hooks/test_wiki_structure_lint.py` (append `TestHookTiering`)
- [ ] **Step 1: Write the failing test**
Append:
```python
class TestHookTiering(unittest.TestCase):
def _write(self, root, rel, fm, body):
p = root / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("---\n" + fm + "\n---\n" + body)
return p
def _event(self, p):
return {"tool_name": "Edit", "tool_input": {"file_path": str(p)}}
def test_completed_missing_section_blocks(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
# 완성 선언(status: verified) + 필수 섹션 누락 → fix-up exit 2
p = self._write(root, "wiki/concepts/x.md",
"title: x\nsource_type: concept\nstatus: verified\ntags: [a]", "본문만\n")
self.assertEqual(wsl.run_hook(self._event(p), root), 2)
def test_draft_missing_section_warns_only(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
# draft → 완성 선언 아님 → exit 0 (WARN)
p = self._write(root, "wiki/concepts/x.md",
"title: x\nsource_type: concept\nstatus: draft\ntags: [a]", "본문만\n")
self.assertEqual(wsl.run_hook(self._event(p), root), 0)
def test_non_wiki_path_passes(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
p = self._write(root, "docs/x.md", "title: x", "본문\n")
self.assertEqual(wsl.run_hook(self._event(p), root), 0)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -k HookTiering`
Expected: FAIL — `AttributeError: ... has no attribute 'run_hook'`.
- [ ] **Step 3: Refactor `--hook` into `run_hook`**
Extract the current `if args.hook:` body (lines ~486-531) into a function returning an exit code, adding the tier decision + suppressed line:
```python
def run_hook(event, root):
"""PostToolUse: 완성 선언 문서의 C1/C3/DANGLING(FIXUP) → exit 2 fix-up. 그 외 WARN(0)."""
inp = event.get("tool_input") or {}
fp = next((inp[k] for k in ("file_path", "path", "absolute_path", "TargetFile", "target_file")
if isinstance(inp.get(k), str)), None)
if not fp or not fp.endswith(".md"):
return 0
p = Path(fp)
if not p.is_absolute():
p = (root / fp)
try:
rel = p.resolve().relative_to(root).as_posix()
except Exception:
return 0
if not (rel.startswith("raw/") or rel.startswith("wiki/")) or not p.exists():
return 0
vp, vb = build_vault_index(root)
doc = parse_doc(p)
findings = check_c2(doc, vp, vb, root, {}, rel) # C2 항상
if is_completeness_checkable(doc):
by_st, by_file = build_template_index(root)
tmpl = resolve_template(doc["fm"], by_st, by_file)
if rel.startswith("raw/project-notes/"):
fm_findings = []
if tmpl is not None:
for k in tmpl["fm_keys"]:
if k not in doc["fm_keys"]:
fm_findings.append(("MISSING_FRONTMATTER", 0, f"frontmatter 키 누락: '{k}'"))
findings = fm_findings + check_project_proxies(doc) + findings
else:
findings = check_c1(doc, tmpl) + findings + check_c3(doc)
if not findings:
return 0
fixup = [f for f in findings if f[0] in wiki_rules.FIXUP_CODES]
block = bool(fixup) and is_completeness_checkable(doc)
sigil = "✗" if block else "⚠"
print(f"{sigil} wiki-structure-lint: {rel} — 구조/링크 이슈 {len(findings)}건"
+ (" → fix 필요" if block else ""), file=sys.stderr)
for code, ln, msg in findings[:10]:
loc = f":{ln}" if ln else ""
print(f" [{code}]{loc} {msg}", file=sys.stderr)
if len(findings) > 10:
print(f" … 외 {len(findings) - 10}건 (suppressed)", file=sys.stderr)
print(" 깨진 링크는 타깃 생성/수정(placeholder 는 `백틱 코드경로`). "
"섹션/선택조건은 완성 선언 문서에만 검사됨.", file=sys.stderr)
return 2 if block else 0
```
Replace the old `if args.hook:` block body with:
```python
if args.hook:
import json as _json
try:
event = _json.loads(sys.stdin.read() or "{}")
except Exception:
sys.exit(0)
sys.exit(run_hook(event, root))
```
Keep `build_vault_index`/`parse_doc`/`check_c1`/`check_c3`/`is_completeness_checkable`/`check_project_proxies`/`resolve_template`/`build_template_index` as-is (already module-level).
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -k HookTiering`
Expected: PASS (3 tests).
- [ ] **Step 5: Checkpoint (no commit)**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py` → all green.
---
## Task 5: Wire `--pre` into `settings.json`
**Files:**
- Modify: `.claude/settings.json` (add a second PreToolUse entry)
- [ ] **Step 1: Edit `settings.json`**
Change the `PreToolUse` array from one matcher to two (leave PostToolUse / Subagent blocks untouched):
```json
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/wiki_claim_gate.py",
"timeout": 30 }
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{ "type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/wiki_structure_lint.py --pre",
"timeout": 30 }
]
}
],
```
- [ ] **Step 2: Validate JSON**
Run: `python3 -m json.tool .claude/settings.json > /dev/null && echo OK`
Expected: `OK`.
- [ ] **Step 3: Checkpoint (no commit)** — JSON valid.
---
## Task 6: Full regression + acceptance smoke (spec §6)
**Files:** none (verification only)
- [ ] **Step 1: Run the full hook test suite**
Run:
```bash
python3 .claude/hooks/test_wiki_rules.py && \
python3 .claude/hooks/test_wiki_claim_gate.py && \
python3 .claude/hooks/test_wiki_structure_lint.py
```
Expected: all suites OK, zero failures.
- [ ] **Step 2: `--all` regression (gate wiring must not change findings)**
Run: `python3 .claude/hooks/wiki_structure_lint.py --all`
Expected: a summary line `== 요약: N개 중 FAIL f / PASS p ==`. Compare `f` against a pre-change baseline (run the same on `git stash` of the hooks if unsure) — the FAIL set must be unchanged (the `--all` path is untouched by this spec).
- [ ] **Step 3: Acceptance criterion 1 — `--pre` blocks ghost, passes backtick (live stdin)**
Run:
```bash
echo '{"tool_name":"Write","tool_input":{"file_path":"raw/branch-notes/_smoke.md","content":"# t\nsee [[raw/nonexistent/ghost]]\n"}}' \
| python3 .claude/hooks/wiki_structure_lint.py --pre; echo "exit=$?"
echo '{"tool_name":"Write","tool_input":{"file_path":"raw/branch-notes/_smoke.md","content":"# t\nfuture: `[[raw/nonexistent/ghost]]`\n"}}' \
| python3 .claude/hooks/wiki_structure_lint.py --pre; echo "exit=$?"
```
Expected: first `exit=2` with a stderr `[BROKEN_LINK]` line; second `exit=0`.
- [ ] **Step 4: Acceptance criterion 3 — `--hook` fix-up only on completion**
Create a temp completed concept doc missing required sections, pipe an Edit event, expect exit 2; flip `status: verified``status: draft`, expect exit 0. (Use a path under `raw/` or `wiki/` in the real repo or a temp `--root`.)
```bash
python3 - <<'PY'
import json, subprocess, tempfile, os
from pathlib import Path
d = tempfile.mkdtemp()
root = Path(d); (root/"wiki"/"concepts").mkdir(parents=True)
# copy templates so resolve_template works
import shutil; shutil.copytree(".claude/hooks", root/".claude"/"hooks"); shutil.copytree("templates", root/"templates")
p = root/"wiki"/"concepts"/"x.md"
def run(status):
p.write_text(f"---\ntitle: x\nsource_type: concept\nstatus: {status}\ntags: [a]\n---\n본문만\n")
ev = json.dumps({"tool_name":"Edit","tool_input":{"file_path":str(p)}})
r = subprocess.run(["python3", str(root/".claude"/"hooks"/"wiki_structure_lint.py"),
"--hook", "--root", str(root)], input=ev, text=True, capture_output=True)
print(status, "exit", r.returncode)
run("verified"); run("draft")
PY
```
Expected: `verified exit 2`, `draft exit 0`.
- [ ] **Step 5: Acceptance criterion 8 — happy path passes both gates**
Take a real completed wiki doc with valid links; run it through `--pre` (Write event) and `--hook` (Edit event). Both must `exit=0`. Confirms the gate doesn't block legitimate writes.
- [ ] **Step 6: Final checkpoint (no commit)**
All §6 acceptance criteria (1, 3, 4, 7, 8) demonstrated green. Report results to the user. Do NOT commit (per user instruction) — leave changes staged in the working tree for the user to review.
---
## Self-Review (completed by plan author)
- **Spec coverage:** §3 DD1 hybrid gate → Tasks 3+4+5. §3 DD2 lean SSOT → Tasks 1+2. §4.1 wiki_rules → Task 1. §4.2 `--pre` → Task 3. §4.3 `--hook` tiering + suppressed → Task 4. §4.4 settings → Task 5. §6 acceptance 1-8 → Task 6. G6 (claim_gate tests) → Task 2. No spec requirement left unmapped.
- **Placeholder scan:** the only `...` is the explicit "copy these 9 functions verbatim from claim_gate lines X-Y" instruction with exact source line ranges — not a content gap. All test/impl steps carry runnable code.
- **Type/name consistency:** `run_pre(event, root)` / `run_hook(event, root)` signatures match between Tasks 3/4 impl and their tests; `CRITICAL_CODES`/`FIXUP_CODES`/`CLAIM_REQUIREMENTS` names match between Task 1 (def) and Tasks 2/3/4 (use); `check_markdown_write(rel, text)` signature unchanged (Task 2 tests call it as today).