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>
78 lines
2.2 KiB
Python
78 lines
2.2 KiB
Python
"""Stale plan check for librarian Stage 1.
|
|
|
|
Walks docs/plans/*.md (top-level only — archive subdir is separate)
|
|
and flags plans whose last git commit is older than STALE_DAYS.
|
|
Plans should be either actively worked on or archived (moved to
|
|
docs/plans/archive/) — long-untouched files are usually drift.
|
|
|
|
Usage:
|
|
uv run python scripts/librarian_check_stale_plans.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
STALE_DAYS = 60
|
|
|
|
|
|
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 _last_commit_date(root: Path, rel: Path) -> str | None:
|
|
result = subprocess.run(
|
|
["git", "log", "-1", "--format=%cs", "--", str(rel)],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=root,
|
|
check=False,
|
|
)
|
|
out = result.stdout.strip()
|
|
return out if out else None
|
|
|
|
|
|
def main() -> int:
|
|
root = _repo_root()
|
|
plans_dir = root / "docs" / "plans"
|
|
if not plans_dir.is_dir():
|
|
print("docs/plans not found; skipping stale-plan check.")
|
|
return 0
|
|
|
|
cutoff = (datetime.now() - timedelta(days=STALE_DAYS)).date()
|
|
|
|
stale: list[tuple[Path, str]] = []
|
|
for path in sorted(plans_dir.glob("*.md")):
|
|
rel = path.relative_to(root)
|
|
last = _last_commit_date(root, rel)
|
|
if last is None:
|
|
stale.append((rel, "untracked"))
|
|
continue
|
|
try:
|
|
last_date = datetime.strptime(last, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
continue
|
|
if last_date < cutoff:
|
|
stale.append((rel, last))
|
|
|
|
if not stale:
|
|
print(f"No stale plans (all touched within {STALE_DAYS} days).")
|
|
return 0
|
|
|
|
print(f"Stale plan candidates (last commit > {STALE_DAYS} days ago):")
|
|
for rel, last in stale:
|
|
print(f" {last} {rel}")
|
|
print()
|
|
print("Action: archive (mv to docs/plans/archive/) or update if still active.")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|