Oannes Claim Identity & Link Repair Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give claims a stable identity so episode→claim links stop dangling, and repair the ~1,768 existing dangling links / 407 orphan claims across the vault.
Architecture: Two phases. Phase 1 (deterministic, high-impact): route every claim filename and every claim link through one shared canonical-name function, then run a one-time migration that rewrites existing episode links to match the real claim files. This fixes the mismatches caused by inconsistent normalization/truncation. Phase 2 (semantic, optional): add a stable id to each claim’s frontmatter and a Claude-assisted dedup pass to merge claims that are the same assertion phrased differently (the residue Phase 1 can’t reach).
Tech Stack: Python 3, pytest, the existing Oannes pipeline (generate.py, shared/io.py, enrich_links.py), Obsidian vault markdown with YAML frontmatter, Claude CLI (claude --print) for the semantic pass.
Background: the two failure mechanisms
-
Inconsistent normalization (deterministic — Phase 1 target). - File creation:
generate_claim_filebuildssafe_filename("Claim - {summary}")then truncates to 117 chars + “…” (generate.py:406-411). - Link creation:_resolve_claim_namereturnsf"Claim - {extracted_summary}"raw — nosafe_filename, no truncation (generate.py:729-730). - Result: any summary with unsafe characters or >120 chars yields a link that cannot match its own file. This is mechanical and fully repairable in code. -
Wording drift (semantic — Phase 2 target). - The same assertion is summarized with different words by the extraction step vs. the resolution step vs. across episodes (e.g. “…hovered over a Minuteman II silo at Ellsworth AFB…” vs. “…was hovering directly over the November 5 silo blast door…”). - No string-normalization can reconcile these; only semantic matching can.
File Structure
- Modify
oannes/Oannes/_scripts/shared/io.py— addcanonical_claim_name(summary), the single source of truth for a claim’s filename stem and link text. - Modify
oannes/Oannes/_scripts/generate.py—generate_claim_fileand_resolve_claim_nameboth callcanonical_claim_name; episode-body claim links use it too. - Create
oannes/Oannes/_scripts/migrate_claim_links.py— one-time deterministic repair of existing episode links. - Create
oannes/Oannes/_scripts/tests/test_claim_identity.py— unit tests for the canonical function and the migration mapping logic. - Phase 2 — Modify
generate.py(writeid:into claim frontmatter), Createoannes/Oannes/_scripts/dedup_claims.py(Claude-assisted merge), Createtests/test_dedup_claims.py.
Run all commands from oannes/Oannes/_scripts/. Run tests with python3 -m pytest.
Phase 1 — Deterministic canonical naming + link repair
Task 1: Single canonical claim-name function
Files:
- Modify: oannes/Oannes/_scripts/shared/io.py
- Test: oannes/Oannes/_scripts/tests/test_claim_identity.py
- [ ] Step 1: Write the failing test
# tests/test_claim_identity.py
from shared.io import canonical_claim_name, safe_filename
def test_canonical_is_safe_and_truncated():
long = "x" * 300
name = canonical_claim_name(long)
assert name.startswith("Claim - ")
assert len(name) <= 120
# must equal what a filesystem-safe filename would be
assert name == safe_filename(name)
def test_canonical_strips_unsafe_chars():
name = canonical_claim_name('UFO over "Area 51": part 1/2?')
for ch in '/\\:|<>"?*':
assert ch not in name
def test_canonical_is_idempotent():
once = canonical_claim_name("A spherical UFO hovered over a silo")
twice = canonical_claim_name(once.removeprefix("Claim - "))
assert once == twice
- [ ] Step 2: Run test to verify it fails
Run: python3 -m pytest tests/test_claim_identity.py -v
Expected: FAIL with ImportError: cannot import name 'canonical_claim_name'
- [ ] Step 3: Write minimal implementation
# shared/io.py — add below safe_filename
CLAIM_NAME_MAX = 120
def canonical_claim_name(summary: str) -> str:
"""Canonical filename stem AND link text for a claim.
The single source of truth: file creation and link generation must both
call this so a claim's file and the links pointing at it always agree.
"""
stem = f"Claim - {summary.strip()}"
stem = safe_filename(stem)
if len(stem) > CLAIM_NAME_MAX:
stem = stem[: CLAIM_NAME_MAX - 3] + "..."
return stem
- [ ] Step 4: Run test to verify it passes
Run: python3 -m pytest tests/test_claim_identity.py -v
Expected: PASS (3 passed)
- [ ] Step 5: Commit
git add oannes/Oannes/_scripts/shared/io.py oannes/Oannes/_scripts/tests/test_claim_identity.py
git commit -m "oannes: add canonical_claim_name as single source of truth for claim naming"
Task 2: Route generation and linking through the canonical function
Files:
- Modify: oannes/Oannes/_scripts/generate.py:406-411 (file creation)
- Modify: oannes/Oannes/_scripts/generate.py:722-730 (_resolve_claim_name)
- Test: oannes/Oannes/_scripts/tests/test_claim_identity.py
- [ ] Step 1: Write the failing test
# append to tests/test_claim_identity.py
from generate import _resolve_claim_name
from shared.io import canonical_claim_name
def test_resolve_claim_name_matches_canonical_for_long_summary():
summary = "A spherical UFO the size of a Walmart building hovered over a Minuteman II missile silo at Ellsworth AFB on November 5 1977"
# resolution has no entry -> falls back to canonical, NOT raw "Claim - {summary}"
resolved = _resolve_claim_name(summary, {"claims": []})
assert resolved == canonical_claim_name(summary)
assert len(resolved) <= 120
- [ ] Step 2: Run test to verify it fails
Run: python3 -m pytest tests/test_claim_identity.py::test_resolve_claim_name_matches_canonical_for_long_summary -v
Expected: FAIL — current code returns raw f"Claim - {summary}" (length 130, not truncated)
- [ ] Step 3: Implement — make both paths call
canonical_claim_name
In generate.py, ensure the import near the other shared.io imports includes it:
from shared.io import safe_filename, atomic_write, canonical_claim_name
Replace the filename block in generate_claim_file (currently generate.py:405-411):
# Canonical claim name = single source of truth (file + links agree)
claim_name = canonical_claim_name(summary)
Replace _resolve_claim_name (currently generate.py:722-730):
def _resolve_claim_name(extracted_summary: str, resolution: dict) -> str | None:
"""Look up a claim's vault name from the resolution data."""
for entry in resolution.get("claims", []):
if entry.get("extracted_summary") == extracted_summary:
action = entry.get("action", "skip")
if action == "skip":
return None
vault_name = entry.get("vault_name")
if vault_name:
# canonicalize the resolution's name the same way files are named
return canonical_claim_name(vault_name.removeprefix("Claim - "))
return canonical_claim_name(extracted_summary)
return canonical_claim_name(extracted_summary)
- [ ] Step 4: Run test to verify it passes
Run: python3 -m pytest tests/test_claim_identity.py -v
Expected: PASS (4 passed)
- [ ] Step 5: Verify imports still load
Run: python3 -c "import generate; print('ok')"
Expected: ok
- [ ] Step 6: Commit
git add oannes/Oannes/_scripts/generate.py oannes/Oannes/_scripts/tests/test_claim_identity.py
git commit -m "oannes: generate claim files and links through canonical_claim_name"
Task 3: Migration — repair existing dangling episode links
Files:
- Create: oannes/Oannes/_scripts/migrate_claim_links.py
- Test: oannes/Oannes/_scripts/tests/test_claim_identity.py
The migration builds a lookup of real claim files, then for every [[Claim - …]]
link in every episode rewrites it to the matching real file. Matching tiers:
(1) exact, (2) canonicalized form of the link, (3) canonicalized truncation-prefix
match against real files. Anything still unmatched is logged, never guessed.
- [ ] Step 1: Write the failing test for the mapping logic
# append to tests/test_claim_identity.py
from migrate_claim_links import best_match
def test_best_match_exact():
files = {"Claim - A hovered over B"}
assert best_match("Claim - A hovered over B", files) == "Claim - A hovered over B"
def test_best_match_via_truncation_prefix():
real = "Claim - " + "y" * 109 + "..." # 120 chars, truncated file
files = {real}
link = "Claim - " + "y" * 200 # raw long link
assert best_match(link, files) == real
def test_best_match_returns_none_when_no_match():
assert best_match("Claim - totally unrelated", {"Claim - something else"}) is None
- [ ] Step 2: Run test to verify it fails
Run: python3 -m pytest tests/test_claim_identity.py -k best_match -v
Expected: FAIL with ModuleNotFoundError: No module named 'migrate_claim_links'
- [ ] Step 3: Implement the migration
# migrate_claim_links.py
"""One-time repair of dangling [[Claim - ...]] links in episode files.
Rewrites each episode's claim links to the canonical filename of the real
claim file they were meant to point at. Logs links it cannot resolve; never
invents a target. Use --dry-run to preview.
"""
import os, re, glob, sys
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)
from shared.io import canonical_claim_name
VAULT = os.path.dirname(SCRIPT_DIR)
EPISODES = os.path.join(VAULT, "Episodes")
CLAIMS = os.path.join(VAULT, "Claims")
LINK_RE = re.compile(r"\[\[(Claim - [^]|#]+?)(\|[^]]+)?\]\]")
def real_claim_files():
return {os.path.basename(p)[:-3] for p in glob.glob(os.path.join(CLAIMS, "*.md"))}
def best_match(link_name, files):
"""Resolve a link to a real claim filename, or None."""
if link_name in files:
return link_name
canon = canonical_claim_name(link_name.removeprefix("Claim - "))
if canon in files:
return canon
# truncation-prefix: a real file is canon's truncated form
stripped = canon[:-3] if canon.endswith("...") else canon
prefix = stripped[: max(0, len(stripped) - 1)]
cands = [f for f in files if f.startswith(prefix[:60]) and f.endswith("...")]
return cands[0] if len(cands) == 1 else None
def migrate(dry_run=True):
files = real_claim_files()
rewritten = unresolved = eps_changed = 0
for ep in glob.glob(os.path.join(EPISODES, "*.md")):
text = open(ep, encoding="utf-8").read()
changed = False
def repl(m):
nonlocal rewritten, unresolved, changed
name, alias = m.group(1).strip(), (m.group(2) or "")
target = best_match(name, files)
if target is None:
unresolved += 1
return m.group(0)
if target != name:
rewritten += 1
changed = True
return f"[[{target}{alias}]]"
return m.group(0)
new_text = LINK_RE.sub(repl, text)
if changed and not dry_run:
open(ep, "w", encoding="utf-8").write(new_text)
if changed:
eps_changed += 1
print(f"{'DRY-RUN ' if dry_run else ''}rewritten={rewritten} "
f"unresolved={unresolved} episodes_changed={eps_changed}")
if __name__ == "__main__":
migrate(dry_run="--apply" not in sys.argv)
- [ ] Step 4: Run test to verify it passes
Run: python3 -m pytest tests/test_claim_identity.py -k best_match -v
Expected: PASS (3 passed)
- [ ] Step 5: Dry-run the migration on the real vault
Run: python3 migrate_claim_links.py
Expected: a line like DRY-RUN rewritten=NNNN unresolved=MMM episodes_changed=KK.
Sanity check: rewritten should be in the high hundreds/low thousands (it targets the ~1,768 dangling links); unresolved is the Phase-2 residue.
- [ ] Step 6: Apply the migration
Run: python3 migrate_claim_links.py --apply
Then verify dangling links dropped:
python3 - <<'PY'
import os, re, glob
cf={os.path.basename(p)[:-3] for p in glob.glob("../Claims/*.md")}
d=sum(1 for ep in glob.glob("../Episodes/*.md")
for l in re.findall(r"\[\[(Claim - [^]|#]+)", open(ep,encoding="utf-8").read())
if l.strip() not in cf)
print("remaining dangling claim links:", d)
PY
Expected: a number far below the starting 1,768 (the remainder is wording-drift for Phase 2).
- [ ] Step 7: Commit
git add oannes/Oannes/_scripts/migrate_claim_links.py oannes/Oannes/_scripts/tests/test_claim_identity.py
git commit -m "oannes: add migrate_claim_links to repair dangling claim links"
Task 4: Re-run link status and confirm orphan drop
Files: none (verification only)
- [ ] Step 1: Re-run the orphan status report
Run: python3 pipeline.py link (re-inserts backrefs now that links resolve)
Then: python3 enrich_links.py status
Expected: claims orphan_pct well below the starting 19% (407 orphans); record the new number.
- [ ] Step 2: Commit any manifest/state change
git add oannes/Oannes/_scripts/manifest.json
git commit -m "oannes: re-link after claim-link repair"
Phase 2 — Stable IDs + semantic dedup (optional, heavier)
Only undertake if Phase 1’s unresolved count (wording-drift residue) is large
enough to matter. This adds a permanent stable identity and merges duplicates.
Task 5: Add a stable id to claim frontmatter
Files:
- Modify: oannes/Oannes/_scripts/generate.py (generate_claim_file frontmatter block, generate.py:426-433)
- Test: oannes/Oannes/_scripts/tests/test_claim_identity.py
- [ ] Step 1: Write the failing test
# append to tests/test_claim_identity.py
from shared.io import claim_id
def test_claim_id_stable_and_slug():
a = claim_id("A spherical UFO hovered over a silo")
b = claim_id("a spherical ufo hovered over a silo") # case/space-insensitive
assert a == b
assert re.fullmatch(r"clm-[0-9a-f]{10}", a)
(Add import re at the top of the test file if not already present.)
- [ ] Step 2: Run test to verify it fails
Run: python3 -m pytest tests/test_claim_identity.py -k claim_id -v
Expected: FAIL with ImportError: cannot import name 'claim_id'
- [ ] Step 3: Implement
claim_id
# shared/io.py
import hashlib, re as _re
def claim_id(summary: str) -> str:
"""Stable content hash id for a claim, robust to case/whitespace noise."""
norm = _re.sub(r"\s+", " ", summary.strip().lower())
norm = _re.sub(r"[^\w ]", "", norm)
digest = hashlib.sha1(norm.encode("utf-8")).hexdigest()[:10]
return f"clm-{digest}"
- [ ] Step 4: Run test to verify it passes
Run: python3 -m pytest tests/test_claim_identity.py -k claim_id -v
Expected: PASS
- [ ] Step 5: Write
idinto new claim frontmatter
In generate_claim_file, add to the frontmatter lines list (after type: claim):
f'id: "{claim_id(summary)}"',
- [ ] Step 6: Backfill
idinto existing claim files
# run once from _scripts/
python3 - <<'PY'
import glob, re, os
from shared.io import claim_id
for p in glob.glob("../Claims/*.md"):
t = open(p, encoding="utf-8").read()
if re.search(r'^id:', t, re.M):
continue
summary = os.path.basename(p)[:-3].removeprefix("Claim - ")
t = t.replace("type: claim", f'type: claim\nid: "{claim_id(summary)}"', 1)
open(p, "w", encoding="utf-8").write(t)
print("backfilled ids")
PY
- [ ] Step 7: Commit
git add oannes/Oannes/_scripts/shared/io.py oannes/Oannes/_scripts/generate.py oannes/Oannes/_scripts/tests/test_claim_identity.py
git commit -m "oannes: add stable claim_id and backfill existing claims"
Task 6: Semantic dedup of wording-drift duplicates
Files:
- Create: oannes/Oannes/_scripts/dedup_claims.py
- Test: oannes/Oannes/_scripts/tests/test_dedup_claims.py
Approach: group claims by shared topics slug (cheap candidate generation),
send each candidate cluster to the Claude CLI asking which claims are the same
assertion, then for each merge-group keep one file and rewrite all links/sources
of the others to it. Conservative: only merge when Claude marks them identical in
meaning, and always log merges.
- [ ] Step 1: Write the failing test for the merge-application logic
# tests/test_dedup_claims.py
from dedup_claims import apply_merges
def test_apply_merges_rewrites_links(tmp_path):
eps = tmp_path / "Episodes"; eps.mkdir()
(eps / "ep.md").write_text("see [[Claim - old wording]] here", encoding="utf-8")
merges = {"Claim - old wording": "Claim - canonical wording"}
changed = apply_merges(str(eps), merges, dry_run=False)
assert changed == 1
assert "[[Claim - canonical wording]]" in (eps / "ep.md").read_text(encoding="utf-8")
- [ ] Step 2: Run test to verify it fails
Run: python3 -m pytest tests/test_dedup_claims.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'dedup_claims'
- [ ] Step 3: Implement
apply_merges(the deterministic, testable core)
# dedup_claims.py
"""Semantic dedup of claims that are the same assertion phrased differently.
Candidate clusters come from shared topic slugs; Claude judges sameness; this
module applies the resulting merge map by rewriting links across episodes.
The Claude-driven clustering lives in build_merge_map() (not unit-tested — it
calls the CLI); apply_merges() is pure and tested.
"""
import os, re, glob
def apply_merges(episodes_dir, merges, dry_run=True):
"""merges: {old_claim_name: keep_claim_name}. Returns episodes changed."""
changed = 0
for ep in glob.glob(os.path.join(episodes_dir, "*.md")):
text = open(ep, encoding="utf-8").read()
new = text
for old, keep in merges.items():
new = new.replace(f"[[{old}]]", f"[[{keep}]]")
if new != text:
changed += 1
if not dry_run:
open(ep, "w", encoding="utf-8").write(new)
return changed
- [ ] Step 4: Run test to verify it passes
Run: python3 -m pytest tests/test_dedup_claims.py -v
Expected: PASS
- [ ] Step 5: Add the Claude clustering driver
# dedup_claims.py — append
import json, subprocess
def build_merge_map(claims_dir, claude_cli="claude"):
"""Cluster claims by shared topic, ask Claude which are duplicates."""
by_topic = {}
for p in glob.glob(os.path.join(claims_dir, "*.md")):
t = open(p, encoding="utf-8").read()
name = os.path.basename(p)[:-3]
for slug in re.findall(r"^\s*-\s*([\w-]+)\s*$", t, re.M):
by_topic.setdefault(slug, []).append(name)
merges = {}
env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}
for slug, names in by_topic.items():
if len(names) < 2:
continue
listing = "\n".join(f"{i+1}. {n}" for i, n in enumerate(names))
prompt = (
"These claims share a topic. Group ones that state the SAME assertion "
"(differently worded). For each group of 2+, pick the clearest as KEEP. "
'Respond ONLY as JSON: [{"keep":"<name>","duplicates":["<name>",...]}].\n\n'
f"{listing}"
)
r = subprocess.run([claude_cli, "--print"], input=prompt,
capture_output=True, text=True, timeout=600, env=env)
out = r.stdout.strip()
s, e = out.find("["), out.rfind("]")
if s == -1 or e == -1:
continue
try:
for g in json.loads(out[s:e+1]):
for dup in g.get("duplicates", []):
if dup != g["keep"]:
merges[dup] = g["keep"]
except json.JSONDecodeError:
continue
return merges
- [ ] Step 6: Dry-run, then apply on the real vault
python3 - <<'PY'
from dedup_claims import build_merge_map, apply_merges
m = build_merge_map("../Claims")
print("merge groups:", len(m))
print("episodes that would change:", apply_merges("../Episodes", m, dry_run=True))
PY
Review the merge map for false positives before applying. To apply: change
dry_run=True to dry_run=False, and additionally delete/redirect the merged
claim files (move duplicate files to an _archive/ folder rather than deleting).
- [ ] Step 7: Commit
git add oannes/Oannes/_scripts/dedup_claims.py oannes/Oannes/_scripts/tests/test_dedup_claims.py
git commit -m "oannes: add semantic claim dedup (topic-clustered, Claude-judged)"
Self-review notes
- Spec coverage: Phase 1 Tasks 1-3 fix the deterministic mismatch (normalization + truncation) that produces most of the 1,768 dangling links; Task 4 verifies the orphan drop; Phase 2 Tasks 5-6 address the wording-drift residue with stable ids + semantic merge. Both failure mechanisms from Background are covered.
- Type consistency:
canonical_claim_name(summary)is used identically ingenerate_claim_file,_resolve_claim_name, andmigrate_claim_links.best_match.claim_idreturns theclm-<10hex>form in both the test and the implementation. - Conservatism: the migration never invents a target (returns
Nonewhen ambiguous); the dedup merges only on explicit Claude sameness and archives rather than deletes — matching the repo rule that the pipeline never destroys data. - Decision point: Phase 2 is gated on Phase 1’s
unresolvedcount. If Phase 1 clears the dangling links to a small number, Phase 2 may be unnecessary.