Fool: FOOL-SEED-RULE.md §2a corrected two days before the beacon — the heading
claimed a ruling scope the recorded ruling could not reach (d6377af, pushed).
Obsidian: the paper bridge tested and working, first notebook transcription
filed under its own entry date; vault-links.py built and twice caught failing
while its selftest read green; Paradigm + Marshall read in full; the Archive
Convention and Re-encounter placed in the vault; the vault's CLAUDE.md
rewritten after fifteen months stale.
Files: session record + notebook-provenance reference memory + 6 KG lines
(3 drift-patterns, 2 preventions, 1 measured-state); MEMORY.md rotated with
the prior Active Session demoted verbatim to MEMORY-reference.md;
scripts/vault-links.py archive-path fix (substring -> path component, 3 new
controls, 26/26).
No new PENDING items — today's vault work is steward-direct and touches no
governed artifact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQKeKY9T9d95KpvHwwok8T
330 lines
16 KiB
Python
Executable File
330 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
vault-links.py — resolve Obsidian wikilinks from the FILES, not from Obsidian.
|
|
|
|
Why this exists, and why not the alternatives (measured 2026-08-23):
|
|
|
|
* Obsidian's own index is a 43 MB LevelDB under ~/Library/Application Support/obsidian/.
|
|
It is a CACHE written by the app: at 18:04 it was 78 minutes stale, and contained ZERO
|
|
entries for a note created at 16:42 the same day (control: 29 entries for a note that
|
|
had existed for months, so the search could see one when present). It also carries a
|
|
LOCK — freshest while Obsidian runs, readable only while it does not. Undocumented and
|
|
version-dependent besides.
|
|
* The Local REST API plugin exposes the live index, but only while Obsidian is open,
|
|
which a SessionStart hook cannot assume.
|
|
|
|
So: implement the RULES, read the FILES. Always current, works with the app shut,
|
|
version-independent, and — the point — testable. `--selftest` carries positive controls
|
|
(it must find a break that is really there) and negative controls (it must NOT report one
|
|
that isn't, and must NOT invent a near-match).
|
|
|
|
THE REFUSAL THAT MATTERS. This tool never fuzzy-matches. On 2026-08-23 a fuzzy matcher
|
|
proposed rewriting [[2025-05-30]] to [[2025-08-30]] and [[2025-07-24]] to [[2025-05-24]].
|
|
Those are different days, not aliases; applying them would have silently falsified the
|
|
steward's own record. Candidates are ADVISORY, are never emitted for date-shaped targets,
|
|
and are never applied by this tool.
|
|
"""
|
|
import argparse, json, os, re, sys, tempfile, shutil
|
|
from urllib.parse import unquote
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
DEFAULT_VAULT = os.path.expanduser(
|
|
"~/Library/Mobile Documents/iCloud~md~obsidian/Documents/David, root-and-branch")
|
|
|
|
FENCE = re.compile(r"^```.*?^```", re.S | re.M)
|
|
TILDE = re.compile(r"^~~~.*?^~~~", re.S | re.M)
|
|
INLINE = re.compile(r"`[^`\n]*`")
|
|
WIKI = re.compile(r"!?\[\[([^\]\n]+)\]\]")
|
|
MDLINK = re.compile(r"(?<!!)\[[^\]\n]*\]\(([^)\s#][^)\s]*)\)")
|
|
# A markdown link is a VAULT link only if it is a relative path to a .md file.
|
|
# Earned 2026-08-23: without this the scanner reported 8,296 dead targets where 466
|
|
# were real — 7,830 were web paths (/essays/, /fragments/) and app URLs (drafts://)
|
|
# pasted in from ARC. The selftest passed 18/18 throughout, because its fixture had no
|
|
# web links. PASS-BUT-FALSELY: the controls below exist so it cannot recur silently.
|
|
def is_vault_mdlink(t):
|
|
if "://" in t or t.startswith(("/", "#", "mailto:")):
|
|
return False
|
|
return t.split("#")[0].lower().endswith(".md")
|
|
DATEY = re.compile(r"^\d{4}[-.]\d{2}([-.]\d{2})?$") # 2025-05-30, 2026.08, 2025-W-less
|
|
WEEKY = re.compile(r"^\d{4}-W\d{1,2}$")
|
|
|
|
|
|
def strip_code(text):
|
|
"""Remove fenced blocks and inline spans. A link inside code is documentation, not a link."""
|
|
text = FENCE.sub(" ", text)
|
|
text = TILDE.sub(" ", text)
|
|
return INLINE.sub(" ", text)
|
|
|
|
|
|
def frontmatter(text):
|
|
if not text.startswith("---"):
|
|
return ""
|
|
end = text.find("\n---", 3)
|
|
return text[3:end] if end != -1 else ""
|
|
|
|
|
|
def parse_aliases(fm):
|
|
"""Obsidian accepts `aliases: [a, b]`, `aliases: a`, and a block list."""
|
|
m = re.search(r"^aliases?:(.*)$", fm, re.M)
|
|
if not m:
|
|
return []
|
|
out, inline = [], m.group(1).strip()
|
|
if inline.startswith("["):
|
|
out += [v.strip().strip("\"'") for v in re.findall(r"[^\[\],]+", inline) if v.strip()]
|
|
elif inline:
|
|
out.append(inline.strip("\"'"))
|
|
else:
|
|
for line in fm[m.end():].split("\n")[1:]:
|
|
mm = re.match(r"^\s+-\s*(.+?)\s*$", line)
|
|
if not mm:
|
|
break
|
|
out.append(mm.group(1).strip("\"'"))
|
|
return [a for a in out if a]
|
|
|
|
|
|
def target_of(raw):
|
|
"""[[Target|display]] / [[Target#Heading]] / [[Target#^block]] -> Target."""
|
|
t = raw.split("|", 1)[0]
|
|
t = t.split("#", 1)[0]
|
|
return t.strip()
|
|
|
|
|
|
class Index:
|
|
def __init__(self, vault):
|
|
self.vault = Path(vault)
|
|
self.by_relpath, self.by_name, self.by_alias = {}, defaultdict(list), {}
|
|
for p in self.vault.rglob("*"):
|
|
if not p.is_file() or "/.obsidian/" in str(p) or p.name.startswith("."):
|
|
continue
|
|
rel = p.relative_to(self.vault).as_posix()
|
|
self.by_relpath[rel] = p
|
|
if p.suffix == ".md":
|
|
self.by_relpath[rel[:-3]] = p
|
|
self.by_name[p.stem].append(p)
|
|
for a in parse_aliases(frontmatter(p.read_text(errors="replace"))):
|
|
self.by_alias.setdefault(a, p)
|
|
else:
|
|
self.by_name[p.name].append(p)
|
|
self.lower_relpath = {k.lower(): v for k, v in self.by_relpath.items()}
|
|
self.lower_name = {k.lower(): v for k, v in self.by_name.items()}
|
|
self.lower_alias = {k.lower(): v for k, v in self.by_alias.items()}
|
|
|
|
def resolve(self, target):
|
|
"""Obsidian's order: exact path -> exact basename -> alias -> case-insensitive.
|
|
Ambiguous basenames take the shortest path, as Obsidian does. Never fuzzy."""
|
|
if not target or target.startswith(("http://", "https://", "mailto:")):
|
|
return ("external", None, "")
|
|
t = target.strip().lstrip("./")
|
|
if t in self.by_relpath:
|
|
return ("ok", self.by_relpath[t], "path")
|
|
hits = self.by_name.get(t) or self.by_name.get(t + ".md")
|
|
if hits:
|
|
best = min(hits, key=lambda p: (len(p.relative_to(self.vault).parts), len(str(p))))
|
|
return ("ok", best, "name" + (" [ambiguous, shortest path]" if len(hits) > 1 else ""))
|
|
if t in self.by_alias:
|
|
return ("ok", self.by_alias[t], "alias")
|
|
base = t.split("/")[-1]
|
|
hits = self.by_name.get(base)
|
|
if hits:
|
|
best = min(hits, key=lambda p: (len(p.relative_to(self.vault).parts), len(str(p))))
|
|
return ("ok", best, "basename-of-path")
|
|
low = t.lower()
|
|
for table, kind in ((self.lower_relpath, "path"), (self.lower_alias, "alias")):
|
|
if low in table:
|
|
v = table[low]
|
|
return ("case", v if isinstance(v, Path) else v, f"case-insensitive {kind}")
|
|
if low in self.lower_name:
|
|
best = min(self.lower_name[low],
|
|
key=lambda p: (len(p.relative_to(self.vault).parts), len(str(p))))
|
|
return ("case", best, "case-insensitive name")
|
|
return ("dead", None, "")
|
|
|
|
def candidates(self, target):
|
|
"""ADVISORY ONLY. Never for date-shaped targets — see the module docstring."""
|
|
base = target.split("/")[-1]
|
|
if DATEY.match(base) or WEEKY.match(base):
|
|
return []
|
|
pref = re.compile(r"^\d{1,2}[a-z]?\.\s+")
|
|
return sorted({n for n in self.by_name if pref.match(n) and pref.sub("", n) == base})
|
|
|
|
|
|
ARCHIVE_DIR = "99. Archive"
|
|
|
|
def in_archive(path, vault):
|
|
"""Exact path-COMPONENT match, never a substring.
|
|
|
|
Earned 2026-08-23, hours after this file was written to avoid exactly this class:
|
|
`"99. Archive" in str(p)` also matches `99. Archives—Previous Iterations`, an ARC
|
|
folder inside 00. Compass. 78 live notes were silently excluded from every "live
|
|
vault" figure of the day (1153 counted as archive; the real archive holds 1070).
|
|
A substring is not a path."""
|
|
return ARCHIVE_DIR in path.relative_to(vault).parts
|
|
|
|
|
|
def scan(vault, include_archive=False, once_per_note=True):
|
|
idx = Index(vault)
|
|
dead, case_only, total = defaultdict(set), defaultdict(set), 0
|
|
for p in sorted(idx.vault.rglob("*.md")):
|
|
if "/.obsidian/" in str(p):
|
|
continue
|
|
if not include_archive and in_archive(p, idx.vault):
|
|
continue
|
|
text = strip_code(p.read_text(errors="replace"))
|
|
raws = [target_of(m) for m in WIKI.findall(text)]
|
|
raws += [unquote(t) for t in MDLINK.findall(text) if is_vault_mdlink(t)]
|
|
seen = set()
|
|
for t in raws:
|
|
if once_per_note:
|
|
if t in seen:
|
|
continue
|
|
seen.add(t)
|
|
total += 1
|
|
st, _, _ = idx.resolve(t)
|
|
if st == "dead":
|
|
dead[t].add(p.stem)
|
|
elif st == "case":
|
|
case_only[t].add(p.stem)
|
|
return idx, dead, case_only, total
|
|
|
|
|
|
def chk(label, cond, state):
|
|
state["ok" if cond else "fail"] += 1
|
|
print(f" {'PASS' if cond else 'FAIL'} {label}")
|
|
|
|
|
|
def selftest():
|
|
state = {"ok": 0, "fail": 0}
|
|
d = tempfile.mkdtemp()
|
|
try:
|
|
w = lambda rel, body: (Path(d, rel).parent.mkdir(parents=True, exist_ok=True),
|
|
Path(d, rel).write_text(body, encoding="utf-8"))
|
|
w("Alpha.md", "---\naliases:\n - Al\n - \"Alpha Prime\"\n---\nbody\n")
|
|
w("sub/Beta.md", "plain\n")
|
|
w("sub/deep/Beta.md", "duplicate basename\n")
|
|
w("2025-08-30.md", "a real day\n")
|
|
w("00. Numbered Thing.md", "prefixed\n")
|
|
w("img.png", "")
|
|
w("Source.md",
|
|
"[[Alpha]] [[Al]] [[Alpha Prime]] [[sub/Beta]] [[Alpha|shown]] [[Alpha#Head]]\n"
|
|
"[[Alpha#^blk]] ![[img.png]] [[alpha]] [[Nonexistent Note]] [[2025-05-30]]\n"
|
|
"`[[InlineCode]]` \n```\n[[FencedCode]]\n```\n[md](sub/Beta.md)\n"
|
|
"[web](/essays/) [app](drafts://open?uuid=X) [ext](https://a.b/c)\n"
|
|
"[enc](sub/Beta.md) [gone](sub/Missing.md)\n")
|
|
idx = Index(d)
|
|
R = lambda t: idx.resolve(t)[0]
|
|
|
|
chk("exact filename resolves", R("Alpha") == "ok", state)
|
|
chk("alias resolves (block list)", R("Al") == "ok", state)
|
|
chk("quoted alias resolves", R("Alpha Prime") == "ok", state)
|
|
chk("path-form link resolves [the 52 false positives of 2026-08-23]",
|
|
R("sub/Beta") == "ok", state)
|
|
chk("display text stripped", target_of("Alpha|shown") == "Alpha", state)
|
|
chk("heading anchor stripped", target_of("Alpha#Head") == "Alpha", state)
|
|
chk("block ref stripped", target_of("Alpha#^blk") == "Alpha", state)
|
|
chk("non-markdown attachment resolves", R("img.png") == "ok", state)
|
|
chk("ambiguous basename resolves to the shortest path [Obsidian's rule]",
|
|
idx.resolve("Beta")[1] == Path(d, "sub/Beta.md"), state)
|
|
chk("case-mismatch is reported as 'case', not silently ok and not dead",
|
|
R("alpha") == "case", state)
|
|
|
|
# --- negative controls: it must NOT report what is not there ---
|
|
chk("a genuinely missing note IS reported dead [POSITIVE CONTROL — without this,"
|
|
" every 'dead' count below proves nothing]", R("Nonexistent Note") == "dead", state)
|
|
_, dead, _, _ = scan(d)
|
|
chk("link inside an inline code span is IGNORED [negative control]",
|
|
"InlineCode" not in dead, state)
|
|
chk("link inside a fenced block is IGNORED [negative control]",
|
|
"FencedCode" not in dead, state)
|
|
chk("a markdown-style link to a .md file IS resolved", "sub/Beta.md" not in dead, state)
|
|
chk("an absolute web path is NOT a vault link [/essays/ — 7,830 false positives"
|
|
" on 2026-08-23]", not is_vault_mdlink("/essays/"), state)
|
|
chk("an app URL is NOT a vault link [drafts://]",
|
|
not is_vault_mdlink("drafts://open?uuid=X"), state)
|
|
chk("an external http link is NOT a vault link",
|
|
not is_vault_mdlink("https://a.b/c"), state)
|
|
chk("a relative .md link IS a vault link [POSITIVE CONTROL — proves the three"
|
|
" refusals above are a filter, not a blanket rejection]",
|
|
is_vault_mdlink("sub/Beta.md"), state)
|
|
chk("a BROKEN relative .md link is still caught [POSITIVE CONTROL — the filter"
|
|
" must not have silenced real markdown-link breakage]",
|
|
"sub/Missing.md" in dead, state)
|
|
|
|
# --- THE refusal. This is why the tool exists in this form. ---
|
|
chk("a date-shaped dead link gets NO candidate [2025-05-30 must never be offered"
|
|
" 2025-08-30 — different days, not aliases]",
|
|
idx.candidates("2025-05-30") == [], state)
|
|
chk("candidate machinery still works for a real prefix case [POSITIVE CONTROL —"
|
|
" proves the empty result above is a refusal, not a broken function]",
|
|
idx.candidates("Numbered Thing") == ["00. Numbered Thing"], state)
|
|
chk("a week-shaped target also gets no candidate", idx.candidates("2026-W30") == [], state)
|
|
|
|
# --- the clean-vault control ---
|
|
d2 = tempfile.mkdtemp()
|
|
Path(d2, "Only.md").write_text("[[Only]]\n")
|
|
_, dead2, case2, tot2 = scan(d2)
|
|
chk("a vault with no broken links reports ZERO [negative control — proves the"
|
|
" scanner is not manufacturing findings]", len(dead2) == 0 and tot2 == 1, state)
|
|
shutil.rmtree(d2, ignore_errors=True)
|
|
|
|
# --- archive exclusion must match a path COMPONENT, not a substring ---
|
|
d3 = Path(tempfile.mkdtemp())
|
|
(d3 / "99. Archive").mkdir(); (d3 / "99. Archives—Previous Iterations").mkdir()
|
|
(d3 / "99. Archive" / "Old.md").write_text("x\n")
|
|
(d3 / "99. Archives—Previous Iterations" / "Live.md").write_text("x\n")
|
|
(d3 / "Plain.md").write_text("x\n")
|
|
chk("a note in '99. Archive/' IS excluded [positive control]",
|
|
in_archive(d3 / "99. Archive" / "Old.md", d3), state)
|
|
chk("a note in '99. Archives—Previous Iterations/' is NOT excluded [the 78-note"
|
|
" over-match of 2026-08-23 — a substring is not a path]",
|
|
not in_archive(d3 / "99. Archives—Previous Iterations" / "Live.md", d3), state)
|
|
chk("a note at the vault root is NOT excluded", not in_archive(d3 / "Plain.md", d3), state)
|
|
shutil.rmtree(d3, ignore_errors=True)
|
|
finally:
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
print(f"\n{'SELFTEST PASS' if not state['fail'] else 'SELFTEST FAIL'}"
|
|
f" — {state['ok']} ok, {state['fail']} failed")
|
|
return 0 if not state["fail"] else 1
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Resolve Obsidian wikilinks from the files.")
|
|
ap.add_argument("--vault", default=DEFAULT_VAULT)
|
|
ap.add_argument("--selftest", action="store_true")
|
|
ap.add_argument("--include-archive", action="store_true")
|
|
ap.add_argument("--json", action="store_true")
|
|
ap.add_argument("--candidates", action="store_true",
|
|
help="show ADVISORY repair candidates (never dates; never applied)")
|
|
a = ap.parse_args()
|
|
if a.selftest:
|
|
return selftest()
|
|
if not os.path.isdir(a.vault):
|
|
print(f"STOP: no vault at {a.vault}", file=sys.stderr)
|
|
return 2
|
|
idx, dead, case_only, total = scan(a.vault, a.include_archive)
|
|
if a.json:
|
|
print(json.dumps({"total_links": total,
|
|
"dead": {k: sorted(v) for k, v in dead.items()},
|
|
"case_only": {k: sorted(v) for k, v in case_only.items()}}, indent=1))
|
|
return 0
|
|
scope = "whole vault" if a.include_archive else "live vault (99. Archive excluded)"
|
|
print(f"vault: {a.vault}\nscope: {scope} links checked: {total} "
|
|
f"(once per note per target)\n")
|
|
print(f"UNRESOLVED : {len(dead)} targets, {sum(len(v) for v in dead.values())} references")
|
|
print(f"CASE-ONLY : {len(case_only)} targets [resolve in Obsidian; a rename would break them]")
|
|
for t, srcs in sorted(dead.items(), key=lambda kv: -len(kv[1]))[:25]:
|
|
line = f" {len(srcs):3d} {t}"
|
|
if a.candidates:
|
|
c = idx.candidates(t)
|
|
if c:
|
|
line += f"\n advisory candidate: {c[0]} [NOT applied]"
|
|
print(line)
|
|
if len(dead) > 25:
|
|
print(f" … {len(dead)-25} more (use --json for all)")
|
|
print("\nNo fuzzy matching is performed. Candidates are advisory and never date-shaped.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|