vault-cli: Librarian tooling for the R/J/F/I vault
Adds packages/vault-cli (the `vault` CLI: search/show/tree, clobber-safe create/move/rename across both /vault and /data + manifest regen, sync-data, report, analyze) and wires it onto $PATH via modules/hosts/core/vault.nix for server + vault-client hosts. Scripts self-locate siblings via realpath so they run from the Nix store. Includes the deterministic $0 report generator, the topology-manifest generator, and the shared Librarian prompt.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
#!/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 <name>.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()
|
||||
Reference in New Issue
Block a user