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