Add memory-dream skill + additive cross-machine memory sync
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>
This commit is contained in:
195
.claude/skills/memory-dream/scripts/selftest.py
Normal file
195
.claude/skills/memory-dream/scripts/selftest.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user