memory-dream: read-only memory lint/consolidation analyzer (index, backlinks, stale refs, dup clusters, profile drift); additive-only --apply-safe, all merges/deletes are proposals. sync-memory.sh: additive repo<->harness-profile union (no delete/overwrite, conflicts surfaced), wired to a SessionStart hook. Migrates the useful profile-only memories into the synced repo store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
196 lines
8.5 KiB
Python
196 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
selftest.py -- exercises memory_dream.py against a synthetic fixture store.
|
|
|
|
Builds a throwaway repo + profile memory store in a temp dir, runs the analyzer
|
|
both in report-only and --apply-safe modes (as a subprocess, with
|
|
CLAUDETOOLS_ROOT / HOME / CLAUDE_PROJECT_DIR pointed at the fixtures), and
|
|
asserts:
|
|
* each detector fires (orphan, missing index target, broken backlink, stale
|
|
referenced path, overlap cluster, profile drift),
|
|
* --apply-safe is strictly additive (no file deleted, no file overwritten,
|
|
orphan index line appended, profile-only file migrated, differing file
|
|
skipped not clobbered).
|
|
|
|
Stdlib only. Exit 0 on success, 1 on any failed assertion.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
SCRIPT = Path(__file__).resolve().with_name("memory_dream.py")
|
|
PY = sys.executable or "python"
|
|
|
|
FAILURES: list[str] = []
|
|
|
|
|
|
def check(cond: bool, msg: str) -> None:
|
|
status = "[OK]" if cond else "[ERROR]"
|
|
print(f"{status} {msg}")
|
|
if not cond:
|
|
FAILURES.append(msg)
|
|
|
|
|
|
def write(path: Path, text: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
|
|
|
|
def build_fixture(root: Path):
|
|
"""Create repo + profile fixture stores. Returns (repo_root, project_dir, home)."""
|
|
repo_root = root / "repo"
|
|
mem = repo_root / ".claude" / "memory"
|
|
mem.mkdir(parents=True, exist_ok=True)
|
|
|
|
# A real script the memory can reference (exists -> must NOT be flagged).
|
|
write(repo_root / ".claude" / "scripts" / "real.sh", "#!/bin/sh\necho hi\n")
|
|
|
|
# --- memory files ---
|
|
# indexed + clean
|
|
write(mem / "reference_alpha.md",
|
|
"---\nname: Alpha\ndescription: alpha thing\nmetadata:\n type: reference\n---\n"
|
|
"Uses `.claude/scripts/real.sh` which exists.\n")
|
|
# orphan (no index line) + broken backlink + stale referenced path
|
|
write(mem / "feedback_orphan.md",
|
|
"---\nname: Orphan Feedback\ndescription: an orphan\ntype: feedback\n---\n"
|
|
"See [[no_such_memory]] and `scripts/ghost_missing.py` which is gone.\n")
|
|
# two overlapping feedback memories (same slug prefix -> cluster)
|
|
write(mem / "feedback_syncro_aaa.md",
|
|
"---\nname: Syncro AAA\ndescription: syncro billing rule one\ntype: feedback\n---\nbody\n")
|
|
write(mem / "feedback_syncro_bbb.md",
|
|
"---\nname: Syncro BBB\ndescription: syncro billing rule two\ntype: feedback\n---\nbody\n")
|
|
# stale dated project fact
|
|
write(mem / "project_old.md",
|
|
"---\nname: Old Project\ndescription: ancient\ntype: project\n---\n"
|
|
"Migration completed as of 2019-01-01 and never touched since.\n")
|
|
|
|
# --- MEMORY.md index ---
|
|
# references reference_alpha + a MISSING target; omits feedback_orphan (orphan)
|
|
write(mem / "MEMORY.md",
|
|
"# Memory Index\n\n"
|
|
"## Reference\n"
|
|
"- [Alpha](reference_alpha.md) -- alpha thing\n"
|
|
"- [Ghost](reference_ghost.md) -- points at a missing file\n\n"
|
|
"## Feedback\n"
|
|
"- [Syncro AAA](feedback_syncro_aaa.md) -- syncro billing rule one\n"
|
|
"- [Syncro BBB](feedback_syncro_bbb.md) -- syncro billing rule two\n\n"
|
|
"## Project\n"
|
|
"- [Old Project](project_old.md) -- ancient\n")
|
|
|
|
# --- profile store ---
|
|
# slug derivation mirrors memory_dream.profile_memory_dir
|
|
project_dir = repo_root # we set CLAUDE_PROJECT_DIR to repo_root
|
|
home = root / "home"
|
|
slug = re.sub(r"[^A-Za-z0-9]+", "-", str(project_dir.resolve()))
|
|
prof = home / ".claude" / "projects" / slug / "memory"
|
|
prof.mkdir(parents=True, exist_ok=True)
|
|
|
|
# profile-only file (candidate to migrate INTO repo)
|
|
write(prof / "feedback_profile_only.md",
|
|
"---\nname: Profile Only\ndescription: lives only in profile\ntype: feedback\n---\nkeep me\n")
|
|
# same-named in BOTH but DIFFERING content (must be skipped, not overwritten)
|
|
write(prof / "reference_alpha.md",
|
|
"---\nname: Alpha\ndescription: alpha thing\nmetadata:\n type: reference\n---\n"
|
|
"PROFILE VERSION -- different content.\n")
|
|
|
|
return repo_root, project_dir, home, prof
|
|
|
|
|
|
def run_analyzer(repo_root: Path, project_dir: Path, home: Path, *extra) -> str:
|
|
env = dict(os.environ)
|
|
env["CLAUDETOOLS_ROOT"] = str(repo_root)
|
|
env["CLAUDE_PROJECT_DIR"] = str(project_dir)
|
|
env["HOME"] = str(home)
|
|
env["PYTHONIOENCODING"] = "utf-8"
|
|
cmd = [PY, str(SCRIPT), "--no-file", *extra]
|
|
res = subprocess.run(cmd, env=env, capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace")
|
|
return res.stdout + "\n" + res.stderr
|
|
|
|
|
|
def main() -> int:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = Path(td)
|
|
repo_root, project_dir, home, prof = build_fixture(root)
|
|
mem = repo_root / ".claude" / "memory"
|
|
|
|
# ---- report-only run ----
|
|
out = run_analyzer(repo_root, project_dir, home)
|
|
|
|
check("Mode: REPORT-ONLY" in out, "default run is report-only")
|
|
check("feedback_orphan.md" in out and "Orphan files (no index line): 1" in out,
|
|
"detects the orphan file")
|
|
check("reference_ghost.md" in out and "missing files: 1" in out,
|
|
"detects index line pointing at missing file")
|
|
check("[[no_such_memory]]" in out, "detects broken backlink")
|
|
check("ghost_missing.py" in out, "flags stale referenced path")
|
|
check("real.sh" not in out.split("REFERENCED-ARTIFACT")[-1].split("##")[0]
|
|
if "REFERENCED-ARTIFACT" in out else True,
|
|
"does NOT flag an existing referenced path (real.sh)")
|
|
check("feedback_syncro_aaa.md" in out and "feedback_syncro_bbb.md" in out
|
|
and "CLUSTER" in out.upper(), "detects overlap cluster")
|
|
check("project_old.md" in out and "2019-01-01" in out,
|
|
"detects stale dated project fact")
|
|
check("feedback_profile_only.md" in out
|
|
and "MIGRATE INTO repo" in out, "detects profile-only drift")
|
|
check("reference_alpha.md" in out and "differs between repo and profile" in out,
|
|
"detects repo<->profile content conflict")
|
|
check("PROPOSED (needs human approval" in out, "emits PROPOSED section")
|
|
|
|
# ---- snapshot repo state before apply-safe ----
|
|
before = {p.name: p.read_text(encoding="utf-8") for p in mem.glob("*.md")}
|
|
|
|
# ---- apply-safe run (additive only) ----
|
|
out2 = run_analyzer(repo_root, project_dir, home, "--apply-safe")
|
|
|
|
after = {p.name: p.read_text(encoding="utf-8") for p in mem.glob("*.md")}
|
|
|
|
# No file deleted.
|
|
check(set(before).issubset(set(after)), "apply-safe deleted no repo file")
|
|
# Orphan index line appended (file content for non-index unchanged).
|
|
for fn, content in before.items():
|
|
if fn == "MEMORY.md":
|
|
continue
|
|
check(after.get(fn) == content,
|
|
f"apply-safe did not alter memory body: {fn}")
|
|
# MEMORY.md grew (orphan appended) and kept all old lines.
|
|
idx_before = before["MEMORY.md"]
|
|
idx_after = after["MEMORY.md"]
|
|
check("feedback_orphan.md" in idx_after,
|
|
"apply-safe appended orphan index line")
|
|
check(all(line in idx_after for line in idx_before.splitlines() if line.strip()),
|
|
"apply-safe preserved every existing index line")
|
|
# Profile-only migrated INTO repo.
|
|
check("feedback_profile_only.md" in after,
|
|
"apply-safe migrated profile-only file into repo")
|
|
# Differing same-named file was SKIPPED, not overwritten.
|
|
check(after["reference_alpha.md"] == before["reference_alpha.md"],
|
|
"apply-safe did NOT overwrite differing repo file (skipped)")
|
|
# The differing same-named file is surfaced as a drift conflict, not a
|
|
# copy target -- apply-safe leaves it for human review.
|
|
check("reference_alpha.md" in out2
|
|
and "differs between repo and profile" in out2,
|
|
"apply-safe reported the differing file as a conflict (not overwritten)")
|
|
# Profile store itself untouched by dream (dream only writes repo side).
|
|
check((prof / "feedback_profile_only.md").exists(),
|
|
"profile-only source still present after migration")
|
|
|
|
print()
|
|
if FAILURES:
|
|
print(f"[ERROR] {len(FAILURES)} self-test assertion(s) failed:")
|
|
for f in FAILURES:
|
|
print(f" - {f}")
|
|
return 1
|
|
print("[SUCCESS] all self-test assertions passed")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|