#!/usr/bin/env python3 """ vault-librarian.py — deterministic ($0) Librarian report for the R/J/F/I vault. Emits every mechanical fact the Librarian judgment pass needs, so the model only reads a small report instead of the whole vault. NO model calls here. Checks: 1. Broken wikilinks (classified: introduced-guessable vs pre-existing/unresolvable) 2. Cross-tree topology diff (/vault vs /data; _unsorted exempt) 3. JD structural violations: - a category folder that duplicates an existing ID number (the "09 should be 00.09" class) - missing .00 management note per category - missing folder-note (Folder Notes plugin) - extend-notation `NN+ ...` leaves that never got a real ID - notes nested deeper than the ID level 4. To-do harvest (- [ ] ...) with file + context 5. Category health (nearing 10-ID limit; duplicate titles across systems = single-home risk) Usage: python3 vault-librarian.py # JSON python3 vault-librarian.py --pretty # human-readable markdown """ import os, re, sys, json from collections import defaultdict sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import vault_lib as vl TODO_RE = re.compile(r'^\s*[-*]\s+\[ \]\s+(.*)$') DONE_RE = re.compile(r'^\s*[-*]\s+\[[xX]\]\s+(.*)$') def check_broken_links(): broken = vl.find_broken_links(vl.VAULT) guessable, unresolvable = [], [] for b in broken: (guessable if b["guess"] else unresolvable).append(b) return {"guessable": guessable, "unresolvable": unresolvable, "count": len(broken)} def check_topology(): return vl.topology_diff() def check_structure(): out = {"missing_dot00": [], "missing_folder_note": [], "extend_notation": [], "too_deep": []} s = vl.walk_structure(vl.VAULT, include_meta=True) id_nums = set(s["ids"].keys()) # NOTE: the `09 Archive of pre-RJFI tree` subtree is a deliberate garbage dump # (Ryan's ruling 2026-07-22) — it is EXCLUDED from all structural checks. Anything # may accumulate there; we never flag or fix inside it. for dp, dns, fns in os.walk(vl.VAULT): dns[:] = [d for d in dns if d not in vl.SKIP_DIRS] rel = os.path.relpath(dp, vl.VAULT) if vl.is_archived(rel): dns[:] = [] continue # extend-notation leaves for fn in fns: if vl.EXTEND_RE.match(fn): out["extend_notation"].append(os.path.relpath(os.path.join(dp, fn), vl.VAULT)) # (b) missing .00 per category + (c) missing folder-note per structural folder for code, rel in s["cats"].items(): catf = os.path.join(vl.VAULT, rel) has_dot00 = any(vl.id_code(d) == code + ".00" for d in os.listdir(catf) if os.path.isdir(os.path.join(catf, d))) # a .00 may also be a bare note; check for any NN.00 leaf if not has_dot00: out["missing_dot00"].append(code) # folder-notes: every area/cat/id folder should contain .md for level in ("areas", "cats", "ids"): for code, rel in s[level].items(): f = os.path.join(vl.VAULT, rel) note = os.path.join(f, os.path.basename(f) + ".md") if not os.path.exists(note): out["missing_folder_note"].append(rel) return out def _todo_bucket(rel, text): """Classify a to-do as 'long_term' or 'pressing'. long_term: lives in a spec/design/wishlist doc (big standing checklists we don't nag about). pressing: journal entries, *Tasks*/*Todo* notes, or near-term signal (a name/date/question).""" low = rel.lower() fname = os.path.basename(rel).lower() # spec/design/wishlist docs -> long term standing lists long_markers = ("spec", "design", "roadmap", "wishlist", "agora", "about the vault", "the r-j-f-i system") if any(m in low for m in long_markers): return "long_term" # journal & explicit task notes -> pressing if "journal" in low or "11.84" in low or "tasks for" in fname or "todo" in fname or "to-do" in fname: return "pressing" # near-term signal in the text t = text.lower() if t.startswith("respond to") or "awaiting" in t or "?" in text or re.search(r'\b20\d\d-\d\d-\d\d\b', text): return "pressing" return "long_term" def check_todos(): pressing, long_term = [], [] for p in vl.all_notes(vl.VAULT, include_archive=False): rel = os.path.relpath(p, vl.VAULT) try: lines = open(p, encoding="utf-8").read().splitlines() except Exception: continue for i, line in enumerate(lines): m = TODO_RE.match(line) if m: text = m.group(1).strip() item = {"file": rel, "line": i + 1, "text": text[:140]} (pressing if _todo_bucket(rel, text) == "pressing" else long_term).append(item) return {"pressing": pressing, "long_term": long_term, "count": len(pressing) + len(long_term)} def check_category_health(): s = vl.walk_structure(vl.VAULT, include_meta=True) near_limit = [] per_cat = defaultdict(int) for idc in s["ids"]: per_cat[idc[:idc.rindex(".")]] += 1 for cat, n in per_cat.items(): if n >= 9: near_limit.append({"category": cat, "ids": n}) # duplicate ID-note titles across systems (single-home smell) titlemap = defaultdict(list) for code, name in s["ids"].items(): title = name.split(" ", 1)[1] if " " in name else name titlemap[title].append(code) dup_titles = {t: codes for t, codes in titlemap.items() if len(codes) > 1} return {"near_id_limit": near_limit, "duplicate_titles": dup_titles} def build_report(): return { "counts": vl.build_manifest()["counts"], "broken_links": check_broken_links(), "topology": check_topology(), "structure": check_structure(), "todos": check_todos(), "category_health": check_category_health(), } def pretty(r): L = [] c = r["counts"] L.append(f"# Vault Librarian report") L.append(f"{c['systems']} systems · {c['areas']} areas · {c['categories']} categories · {c['ids']} IDs\n") bl = r["broken_links"] L.append(f"## Broken wikilinks: {bl['count']}") if bl["guessable"]: L.append(f"**Likely fixable ({len(bl['guessable'])})** — target renamed, single candidate:") for b in bl["guessable"][:30]: L.append(f" - `{b['file']}`: [[{b['target']}]] → guess [[{b['guess']}]]") if bl["unresolvable"]: L.append(f"**Unresolvable ({len(bl['unresolvable'])})** — target never existed / dropped stub:") for b in bl["unresolvable"][:30]: L.append(f" - `{b['file']}`: [[{b['target']}]]") L.append("") t = r["topology"] L.append("## Cross-tree topology (/vault vs /data)") for level in ("systems", "areas", "cats", "ids"): md = t[level]["missing_in_data"]; od = t[level]["orphan_in_data"] if md: L.append(f" - **{level} missing /data home ({len(md)}):** {', '.join(md[:20])}{' …' if len(md)>20 else ''}") if od: L.append(f" - **{level} orphan in /data ({len(od)}):** {', '.join(od[:20])}") L.append("") st = r["structure"] L.append("## JD structural") if st["extend_notation"]: L.append(f" - **extend-notation `NN+` needing real IDs ({len(st['extend_notation'])}):**") for e in st["extend_notation"]: L.append(f" {e}") if st["missing_dot00"]: L.append(f" - **categories missing a .00 note ({len(st['missing_dot00'])}):** {', '.join(sorted(st['missing_dot00'])[:25])}") if st["missing_folder_note"]: L.append(f" - **folders missing a folder-note ({len(st['missing_folder_note'])}):** {len(st['missing_folder_note'])} (Folder Notes plugin)") L.append("") td = r["todos"] L.append(f"## To-dos: {td['count']} open ({len(td['pressing'])} pressing, {len(td['long_term'])} long-term)") L.append(f"**Pressing ({len(td['pressing'])}):**") for x in td["pressing"][:40]: L.append(f" - `{x['file']}`:{x['line']} {x['text']}") L.append(f"**Long-term ({len(td['long_term'])})** — standing checklists, not nagged:") for x in td["long_term"][:15]: L.append(f" - `{x['file']}`:{x['line']} {x['text']}") if len(td["long_term"]) > 15: L.append(f" … and {len(td['long_term'])-15} more long-term") L.append("") ch = r["category_health"] if ch["near_id_limit"]: L.append("## Category health") for x in ch["near_id_limit"]: L.append(f" - `{x['category']}` has {x['ids']} IDs (near 10 limit)") if ch["duplicate_titles"]: L.append(f" - duplicate ID titles across systems ({len(ch['duplicate_titles'])}): " + "; ".join(f"{t} {v}" for t, v in list(ch["duplicate_titles"].items())[:10])) return "\n".join(L) def main(): r = build_report() if "--pretty" in sys.argv: print(pretty(r)) else: print(json.dumps(r, indent=2, ensure_ascii=False)) if __name__ == "__main__": main()