#!/usr/bin/env python3 """Reject orphaned persona-memory artifacts before push. This guard is deliberately structural: it does not judge prose. It verifies that a new/changed durable memory leaf has the minimum HLDP recovery fields and can be reached from a current navigation page. """ import os, subprocess, sys REQUIRED = ("trigger:", "emergence:", "lock:", "why:", "checkpoint") NAVIGATION = ("INDEX.hdlp", "CURRENT.hdlp", "LL-CURRENT.hdlp", "BROADCAST-TOWER.hdlp") def changed_files(): """Return candidate files for a local check or a server receive range.""" if len(sys.argv) == 3 and sys.argv[1] == "--range": old_rev, new_rev = sys.argv[2].split("..", 1) if old_rev == "0" * 40: command = ["git", "diff-tree", "--root", "--no-commit-id", "-r", "--name-only", "--diff-filter=ACM", new_rev] else: command = ["git", "diff", "--name-only", "--diff-filter=ACM", old_rev, new_rev] result = subprocess.run(command, capture_output=True, text=True) return sorted(x for x in result.stdout.splitlines() if x) if result.returncode == 0 else [] commands = [ ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], ] files = set() for cmd in commands: r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode == 0: files.update(x for x in r.stdout.splitlines() if x) return sorted(files) def is_memory_leaf(path): name = os.path.basename(path) return name.startswith(("ZL-MEM-", "ZY-MEM-", "ZZ-MEM-")) or "/personas/" in path def read(path): with open(path, encoding="utf-8") as f: return f.read() def linked_from_navigation(path): base = os.path.basename(path) for root, _, names in os.walk("."): if ".git" in root.split(os.sep): continue for name in names: if name in NAVIGATION: candidate = os.path.join(root, name) try: if base in read(candidate): return candidate[2:] if candidate.startswith("./") else candidate except OSError: pass return None def main(): failures = [] for path in changed_files(): if not is_memory_leaf(path) or not os.path.isfile(path): continue content = read(path).lower() missing = [field for field in REQUIRED if field not in content] if missing: failures.append((path, "缺永久记忆字段: " + ", ".join(missing))) if not linked_from_navigation(path): failures.append((path, "孤立记忆:未被 INDEX / CURRENT / LL-CURRENT / BROADCAST-TOWER 引用")) if failures: print("\n🧭 记忆—导航守门人:推送被暂停", file=sys.stderr) print("持续记忆必须可恢复,而不是只被写进仓库。", file=sys.stderr) for path, reason in failures: print(f" ✗ {path}\n {reason}", file=sys.stderr) print("修复:补齐 trigger/emergence/lock/why/checkpoint,并在对应 INDEX 或 CURRENT 中建立路径。\n", file=sys.stderr) return 1 print("✅ 记忆—导航守门人:没有发现孤立持续记忆") return 0 if __name__ == "__main__": sys.exit(main())