Files
coorl-lost-cities/scripts/librarian_check_memory_drift.py
T
coolguyandClaude Opus 4.7 6ecb233bdd Complete librarian Stage 1: stale plans + MEMORY drift
Adds the last two deterministic Stage 1 checks and wires them into
scripts/librarian.sh:

- librarian_check_stale_plans.py: per docs/plans/*.md, uses
  `git log -1 --format=%cs` to get the last commit date and flags
  plans untouched in 60+ days. Plans should be either active or
  archived; long silence is drift.
- librarian_check_memory_drift.py: validates the user-memory dir
  (~/.claude/projects/<slug>/memory). Each MEMORY.md index line
  must point at a real file with frontmatter (name, description,
  type ∈ {user, feedback, project, reference}); no orphaned memory
  files. Memory dir is derived from repo root for portability.

Both run clean against current state.

Stage 1 declared complete. Promotable-archive and duplicate-prose
detection are moved to Stage 2 in the plan because both need LLM
judgment to avoid false positives — not pattern matching.

Next concrete step is the open Stage 1 finding from yesterday:
docs/performance.md at 914 lines, due for a split into sub-topic
notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:44:25 +09:00

105 lines
3.3 KiB
Python

"""MEMORY.md drift check for librarian Stage 1.
Validates the user-memory directory:
- Each MEMORY.md index line points to a real memory file.
- Each memory file has YAML frontmatter with required fields
(`name`, `description`, `type`) and a valid `type`.
- Reports orphan memory files (exist on disk but missing from index).
Memory dir is derived from the repo root, mirroring how Claude Code
resolves project-scoped memory paths. If the dir doesn't exist (fresh
checkout), the check exits 0 silently.
Usage:
uv run python scripts/librarian_check_memory_drift.py
"""
from __future__ import annotations
import re
from pathlib import Path
VALID_TYPES = {"user", "feedback", "project", "reference"}
REQUIRED_FIELDS = ("name", "description", "type")
INDEX_LINE = re.compile(r"^\s*-\s*\[([^\]]+)\]\(([^)]+)\)")
FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def _repo_root() -> Path:
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "pyproject.toml").is_file():
return parent
raise RuntimeError("pyproject.toml을 찾을 수 없어 repository root를 판정할 수 없습니다.")
def _memory_dir(root: Path) -> Path:
project_slug = str(root).replace("/", "-")
return Path.home() / ".claude" / "projects" / project_slug / "memory"
def _parse_frontmatter(body: str) -> dict[str, str] | None:
m = FRONTMATTER.match(body)
if not m:
return None
fields: dict[str, str] = {}
for line in m.group(1).splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
fields[key.strip()] = value.strip()
return fields
def main() -> int:
root = _repo_root()
mem_dir = _memory_dir(root)
index_file = mem_dir / "MEMORY.md"
if not index_file.is_file():
print(f"MEMORY.md not found at {mem_dir}; skipping memory check.")
return 0
findings: list[str] = []
referenced: set[str] = set()
for raw in index_file.read_text(encoding="utf-8").splitlines():
m = INDEX_LINE.match(raw)
if not m:
continue
target = m.group(2).strip()
referenced.add(target)
target_path = mem_dir / target
if not target_path.is_file():
findings.append(f"index points to missing file: {target}")
continue
body = target_path.read_text(encoding="utf-8")
fields = _parse_frontmatter(body)
if fields is None:
findings.append(f"{target}: missing or malformed frontmatter")
continue
for key in REQUIRED_FIELDS:
if key not in fields:
findings.append(f"{target}: frontmatter missing `{key}`")
if "type" in fields and fields["type"] not in VALID_TYPES:
findings.append(
f"{target}: invalid type `{fields['type']}` (allowed: {sorted(VALID_TYPES)})"
)
on_disk = {p.name for p in mem_dir.glob("*.md") if p.name != "MEMORY.md"}
for orphan in sorted(on_disk - referenced):
findings.append(f"orphan memory file (not in MEMORY.md index): {orphan}")
if not findings:
print("MEMORY.md and memory files consistent.")
return 0
print("MEMORY.md drift findings:")
for entry in findings:
print(f" {entry}")
return 1
if __name__ == "__main__":
raise SystemExit(main())