← back to Knowledge Layer Skill

knowledge-layer/scripts/kl_status.py

162 lines

#!/usr/bin/env python3
"""
kl_status.py — health report for a Knowledge Layer.

Surfaces the drift that turns "a wiki you keep" into "a wiki you abandon":
  - pending sources (not yet compiled — delegates the diff to kl_scan logic)
  - unresolved contradictions (open items in CONTRADICTIONS.md)
  - orphan cross-refs ([[links]] pointing at entries that don't exist)
  - entries with no source provenance (unfalsifiable claims)
  - entry / source counts per typed folder

Usage:
    kl_status.py [<kl-root>] [--json]

Exit codes: 0 = healthy, 10 = attention needed, 2 = error.
"""
import argparse
import json
import re
import sys
from pathlib import Path

SKIP_TOP = {".kl", "sources"}
ROOT_DOCS = {"SCHEMA.md", "INDEX.md", "CONTRADICTIONS.md", "SOURCES.md"}


def load_manifest(root: Path) -> dict:
    m = root / ".kl" / "manifest.json"
    if m.exists():
        try:
            return json.loads(m.read_text())
        except json.JSONDecodeError:
            pass
    return {"sources": {}, "compiled_at": None}


def count_sources(root: Path) -> int:
    src = root / "sources"
    if not src.is_dir():
        return 0
    return sum(1 for p in src.rglob("*") if p.is_file() and not p.name.startswith("."))


def entries(root: Path):
    for p in sorted(root.rglob("*.md")):
        rel = p.relative_to(root)
        if rel.parts[0] in SKIP_TOP:
            continue
        if p.name in ROOT_DOCS or p.name == "_about.md":
            continue
        yield p


def has_provenance(text: str) -> bool:
    m = re.search(r"^sources:\s*(.*)$", text, re.M)
    if not m:
        return False
    v = m.group(1).strip().strip("[]").strip()
    return bool(v)


def open_contradictions(root: Path) -> int:
    """Count unchecked task-list items (`- [ ]`), skipping anything inside an
    HTML comment (the template ships commented-out examples)."""
    f = root / "CONTRADICTIONS.md"
    if not f.exists():
        return 0
    text = f.read_text()
    # strip <!-- ... --> comment regions (incl. multi-line) before counting
    text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
    n = 0
    for line in text.splitlines():
        if line.strip().lower().startswith("- [ ]"):
            n += 1
    return n


def main() -> int:
    ap = argparse.ArgumentParser(description="Knowledge Layer health report.")
    ap.add_argument("root", nargs="?", default="knowledge-layer")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    root = Path(args.root).resolve()
    if not (root / ".kl").is_dir():
        print(f"ERROR: {root} is not a Knowledge Layer (run kl_init.py first).",
              file=sys.stderr)
        return 2

    manifest = load_manifest(root)
    tracked = set(manifest.get("sources", {}))
    src_now = set()
    src_dir = root / "sources"
    if src_dir.is_dir():
        for p in src_dir.rglob("*"):
            if p.is_file() and not p.name.startswith("."):
                src_now.add(p.relative_to(root).as_posix())
    pending = len((src_now - tracked) | (tracked - src_now))

    stems = set()
    per_folder = {}
    no_prov = []
    all_links = []
    for p in entries(root):
        stems.add(p.stem.lower())
        folder = p.relative_to(root).parts[0]
        per_folder[folder] = per_folder.get(folder, 0) + 1
        text = p.read_text(errors="replace")
        if not has_provenance(text):
            no_prov.append(p.relative_to(root).as_posix())
        for lk in re.findall(r"\[\[([^\]]+)\]\]", text):
            all_links.append((p.relative_to(root).as_posix(), lk.strip().lower()))

    orphans = sorted({f"{src} -> [[{lk}]]" for src, lk in all_links
                      if lk not in stems})
    contradictions = open_contradictions(root)

    report = {
        "root": str(root),
        "last_compiled": manifest.get("compiled_at"),
        "sources_tracked": len(tracked),
        "sources_present": len(src_now),
        "sources_pending": pending,
        "entries_total": len(stems),
        "entries_by_folder": per_folder,
        "open_contradictions": contradictions,
        "orphan_crossrefs": orphans,
        "entries_without_provenance": no_prov,
    }
    attention = bool(pending or contradictions or orphans or no_prov)

    if args.json:
        report["attention"] = attention
        print(json.dumps(report, indent=2))
        return 10 if attention else 0

    print(f"Knowledge Layer: {root}")
    print(f"last compiled : {manifest.get('compiled_at') or 'never'}")
    print(f"sources       : {len(src_now)} present, {pending} pending compile")
    print(f"entries       : {len(stems)} total")
    for f, n in sorted(per_folder.items()):
        print(f"                {n:>4}  {f}/")
    print(f"contradictions: {contradictions} open")
    print(f"orphan links  : {len(orphans)}")
    for o in orphans[:20]:
        print(f"                {o}")
    if len(orphans) > 20:
        print(f"                … and {len(orphans) - 20} more")
    print(f"no provenance : {len(no_prov)} entries")
    for o in no_prov[:20]:
        print(f"                {o}")
    if len(no_prov) > 20:
        print(f"                … and {len(no_prov) - 20} more")
    print()
    print("ATTENTION NEEDED — run a consolidation pass (see references/"
          "wiki-principles.md)." if attention else "Healthy.")
    return 10 if attention else 0


if __name__ == "__main__":
    sys.exit(main())