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

30 KiB

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.pyimport 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.pyimport 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:

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:

#!/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:

#!/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:

#!/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:

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:

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.


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):

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:

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():

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:

    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:

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:

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:

    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):

    "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:

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:

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: verifiedstatus: draft, expect exit 0. (Use a path under raw/ or wiki/ in the real repo or a temp --root.)

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).