From a741ed882fffb8980816eabb73f403366872e55e Mon Sep 17 00:00:00 2001 From: David R Oliver <154228704+DavidROliverBA@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:49:06 +0000 Subject: [PATCH] Add session memory hooks and update ambient memory rules New hooks: - session-learner.py: Post-session capture of structured data to MCP memory graph with triage classification (apply/capture/dismiss) - session-summary.py: Post-session git summary appended to daily notes Updated: - ambient-memory.md: Latest version with entity type enforcement, size management, security warnings for classified content All hooks use relative path resolution (no hardcoded paths). Co-Authored-By: Claude Opus 4.6 --- .claude/hooks/session-learner.py | 523 +++++++++++++++++++++++++++++++ .claude/hooks/session-summary.py | 267 ++++++++++++++++ .claude/rules/ambient-memory.md | 59 +++- 3 files changed, 836 insertions(+), 13 deletions(-) create mode 100755 .claude/hooks/session-learner.py create mode 100644 .claude/hooks/session-summary.py diff --git a/.claude/hooks/session-learner.py b/.claude/hooks/session-learner.py new file mode 100755 index 0000000..37e986b --- /dev/null +++ b/.claude/hooks/session-learner.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python3 +""" +Session Learner Hook for Claude Code +Records session outcomes to MCP memory graph for cross-session learning. + +This hook complements session-summary.py (which writes to the daily note) +by recording structured knowledge to the MCP memory graph. This enables +vault-review to surface trends, lessons, and improvement suggestions at +the start of the next session. + +Hook Type: Notification (Stop) +Exit Codes: + 0 - Always (non-blocking) + +Memory Graph Entity Types Written: + SessionSummary - What happened in this session + VaultHealth - Health snapshot (if health checks were run) + SkillOutcome - Skills executed and their results + KnowledgeGap - Gaps identified during the session + LessonLearned - Patterns observed across sessions + +Design Notes: + - This hook runs at session end alongside session-summary.py + - Primary: reads the Session Log from today's daily note (written by session-summary.py) + - Fallback: reads git log directly if daily note is unavailable + - It analyses commit messages and file paths to infer skill usage and outcomes + - It writes to MCP memory via the Claude Code MCP protocol + - Non-blocking: failures are logged to stderr but never prevent session end + +Integration with Self-Improvement Loop: + 1. This hook RECORDS (Phase 2 of the loop) + 2. vault-review READS the recorded entities (Phase 3 - Analyse) + 3. Improvement suggestions are surfaced based on accumulated patterns + 4. See HLD - Vault Self-Improvement Loop.md for the full architecture + +Implemented: + - Triage classification (Apply/Capture/Dismiss) for session observations + - Pruning of old manifests (keeps last 20) + +Future Enhancements (Phase B/C): + - Parse hook warnings from the session to identify recurring issues + - Compare health metrics with previous sessions for trend detection + - Auto-create LessonLearned entities when patterns repeat 3+ times +""" + +import json +import os +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +# Triage classification for observations +# Apply → Actionable now (fix, update rule, add to CLAUDE.md) +# Capture → Worth recording for trend analysis (may promote later) +# Dismiss → One-off, no future value +TRIAGE_RULES = { + # Patterns that indicate one-off activity (Dismiss) — checked FIRST + "dismiss": [ + r"daily|stand.?up|sync|catch.?up", + r"typo|whitespace|formatting only", + ], + # Patterns that indicate actionable learnings (Apply) + "apply": [ + r"hotfix|bugfix", + r"fix.*(hook|skill|rule|convention|config|claude\.md)", + r"hook.*fail|hook.*block|hook.*error", + r"permission.*denied|sandbox.*block", + r"workaround|hack|temporary", + ], + # Patterns that indicate recurring issues worth tracking (Capture) + "capture": [ + r"refactor|improve|enhance", + r"new.*skill|new.*hook|new.*script", + r"convention|pattern|standard", + r"migration|rename|consolidat", + ], +} + +MAX_SESSION_ENTITIES = 20 # Keep last N SessionSummary manifests + +VAULT_ROOT = Path(__file__).resolve().parent.parent.parent +SKILL_PATTERNS = { + "Meetings/": "meeting", + "Daily/": "daily", + "Tasks/": "task", + "People/": "person", + "ADRs/": "adr", + "Projects/": "project", + "Emails/": "email", + "Incubator/": "incubator", + "Forms/": "form", + "Objectives/": "objective", + "HLD - ": "hld", + "LLD - ": "lld", + "Reference - ": "reference", + "Concept - ": "concept", + "Pattern - ": "pattern", + "System - ": "system", + "Organisation - ": "organisation", + "Tool - ": "tool", + "Framework - ": "framework", + ".claude/skills/": "skill-development", + ".claude/hooks/": "hook-development", + ".claude/scripts/": "script-development", +} + +MAINTENANCE_INDICATORS = [ + "vault-maintenance", + "quality-report", + "broken-links", + "orphans", + "archive", + "auto-tag", + "auto-summary", + "rename", + "template-sync", +] + + +def run_git(*args: str) -> str: + """Run a git command and return stdout, or empty string on failure.""" + try: + result = subprocess.run( + ["git", "-C", str(VAULT_ROOT), *args], + capture_output=True, + text=True, + timeout=10, + ) + return result.stdout.strip() if result.returncode == 0 else "" + except (subprocess.TimeoutExpired, FileNotFoundError): + return "" + + +def get_session_commits() -> list[dict]: + """Get commits from the last 2 hours with hash, message, and files changed.""" + output = run_git( + "log", + "--oneline", + "--name-only", + "--since=2 hours ago", + ) + if not output: + return [] + + commits = [] + current = None + for line in output.splitlines(): + if line and not line.startswith(" ") and " " in line: + parts = line.split(" ", 1) + if len(parts[0]) >= 7 and len(parts[0]) <= 12: + if current: + commits.append(current) + current = { + "hash": parts[0], + "message": parts[1], + "files": [], + } + continue + if current and line.strip(): + current["files"].append(line.strip()) + + if current: + commits.append(current) + return commits + + +def get_daily_note_path() -> Path: + """Return path to today's daily note.""" + today = datetime.now() + return VAULT_ROOT / "Daily" / str(today.year) / f"{today.strftime('%Y-%m-%d')}.md" + + +def get_session_log_from_daily() -> list[dict] | None: + """Extract session commits from today's daily note Session Log section. + + Returns None if daily note doesn't exist or has no session log. + Falls back to git log in that case. + """ + daily_path = get_daily_note_path() + if not daily_path.exists(): + return None + + content = daily_path.read_text(encoding="utf-8") + + # Find the Session Log section + session_log_match = re.search( + r"## Session Log\s*\n(.*?)(?=\n## |\Z)", content, re.DOTALL + ) + if not session_log_match: + return None + + session_text = session_log_match.group(1) + + # Parse commit lines: "- abc1234 Add meeting notes" + # Validate hash is hex to avoid false positives from file-change lines + hex_pattern = re.compile(r"^[0-9a-f]+$", re.IGNORECASE) + commits = [] + for line in session_text.splitlines(): + line = line.strip() + if line.startswith("- ") and len(line) > 10: + parts = line[2:].split(" ", 1) + if len(parts) == 2 and len(parts[0]) >= 7 and hex_pattern.match(parts[0]): + commits.append( + { + "hash": parts[0], + "message": parts[1], + "files": [], + } + ) + + return commits if commits else None + + +def infer_skills_used(commits: list[dict]) -> list[str]: + """Infer which skills were likely used based on commit messages and files.""" + skills = set() + + for commit in commits: + msg = commit["message"].lower() + files = commit.get("files", []) + + # Check commit message for skill indicators + for indicator in MAINTENANCE_INDICATORS: + if indicator in msg: + skills.add(indicator) + + # Check file paths for skill patterns + for filepath in files: + for pattern, skill in SKILL_PATTERNS.items(): + if pattern in filepath: + skills.add(skill) + break + + return sorted(skills) + + +def infer_session_type(commits: list[dict], skills: list[str]) -> str: + """Classify the session type based on what was done.""" + maintenance_skills = set(skills) & set(MAINTENANCE_INDICATORS) + if maintenance_skills: + return "maintenance" + + infra_skills = {"skill-development", "hook-development", "script-development"} + if set(skills) & infra_skills: + return "infrastructure" + + note_skills = { + "meeting", + "daily", + "task", + "person", + "adr", + "email", + "project", + } + if set(skills) & note_skills: + return "knowledge-capture" + + if any("HLD" in c["message"] or "LLD" in c["message"] for c in commits): + return "architecture" + + return "general" + + +def count_files_by_type(commits: list[dict]) -> dict[str, int]: + """Count how many files were changed by type.""" + counts: dict[str, int] = {} + seen: set[str] = set() + for commit in commits: + for filepath in commit.get("files", []): + if filepath in seen: + continue + seen.add(filepath) + ext = Path(filepath).suffix + if ext: + counts[ext] = counts.get(ext, 0) + 1 + return counts + + +def build_session_entity( + commits: list[dict], + skills: list[str], + session_type: str, + file_counts: dict[str, int], + triage_level: str = "capture", +) -> dict: + """Build the SessionSummary entity for MCP memory.""" + today = datetime.now().strftime("%Y-%m-%d") + time = datetime.now().strftime("%H:%M") + + observations = [ + f"Session date: {today} at {time}", + f"Session type: {session_type}", + f"Triage: {triage_level}", + f"Commits: {len(commits)}", + f"Skills used: {', '.join(skills) if skills else 'none detected'}", + ] + + # Add commit summaries (up to 10) + for commit in commits[:10]: + observations.append(f"Commit: {commit['hash']} - {commit['message']}") + + # Add file change summary + total_files = sum(file_counts.values()) + if total_files: + observations.append(f"Total files changed: {total_files}") + md_count = file_counts.get(".md", 0) + if md_count: + observations.append(f"Markdown files changed: {md_count}") + + return { + "name": f"Session-{today}-{time.replace(':', '')}", + "entityType": "SessionSummary", + "observations": observations, + } + + +def build_skill_outcomes(skills: list[str]) -> list[dict]: + """Build SkillOutcome entities for each skill used.""" + today = datetime.now().strftime("%Y-%m-%d") + entities = [] + for skill in skills: + entities.append( + { + "name": f"SkillRun-{skill}-{today}", + "entityType": "SkillOutcome", + "observations": [ + f"Skill: {skill}", + f"Date: {today}", + "Outcome: completed (inferred from git commits)", + ], + } + ) + return entities + + +def write_to_memory( + entities: list[dict], relations: list[dict], triage_level: str = "capture" +) -> None: + """Write entities and relations to MCP memory. + + Note: This hook runs outside the MCP context, so it cannot directly + call MCP tools. Instead, it writes a JSON manifest that vault-review + can read and process at the start of the next session. + """ + manifest_dir = VAULT_ROOT / ".claude" / "memory" + manifest_dir.mkdir(parents=True, exist_ok=True) + + today = datetime.now().strftime("%Y-%m-%d") + time = datetime.now().strftime("%H%M%S") + manifest_path = manifest_dir / f"pending-{today}-{time}.json" + + manifest = { + "created": datetime.now().isoformat(), + "triage": triage_level, + "entities": entities, + "relations": relations, + "status": "pending", + } + + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + print( + f"Session learner: wrote {len(entities)} entities to {manifest_path.name}", + file=sys.stderr, + ) + + +def triage_observation(text: str) -> str: + """Classify an observation as apply, capture, or dismiss. + + Check order: dismiss first (cheap one-offs), then apply (actionable), + then capture (worth tracking). Default is capture. + """ + text_lower = text.lower() + for pattern in TRIAGE_RULES["dismiss"]: + if re.search(pattern, text_lower): + return "dismiss" + for pattern in TRIAGE_RULES["apply"]: + if re.search(pattern, text_lower): + return "apply" + for pattern in TRIAGE_RULES["capture"]: + if re.search(pattern, text_lower): + return "capture" + return "capture" # Default: capture for trend analysis + + +def triage_session(commits: list[dict], skills: list[str]) -> str: + """Classify the entire session's triage level based on commits and skills.""" + classifications = [] + for commit in commits: + classifications.append(triage_observation(commit["message"])) + # Session-level: highest priority wins (apply > capture > dismiss) + if "apply" in classifications: + return "apply" + if "capture" in classifications: + return "capture" + return "dismiss" + + +def prune_old_manifests() -> int: + """Remove oldest session manifests beyond MAX_SESSION_ENTITIES. + + Returns number of pruned files. + """ + manifest_dir = VAULT_ROOT / ".claude" / "memory" + if not manifest_dir.exists(): + return 0 + + manifests = sorted(manifest_dir.glob("pending-*.json")) + if len(manifests) <= MAX_SESSION_ENTITIES: + return 0 + + to_prune = manifests[: len(manifests) - MAX_SESSION_ENTITIES] + pruned = 0 + for manifest_path in to_prune: + try: + manifest_path.unlink() + pruned += 1 + except OSError: + pass + + if pruned: + print( + f"Session learner: pruned {pruned} old manifests (kept last {MAX_SESSION_ENTITIES})", + file=sys.stderr, + ) + return pruned + + +def cleanup_temp_storage() -> None: + """Clean up Claude temp files older than 24 hours to prevent disk bloat. + + Parallel subagents write temp files to /private/tmp/claude-501/ and + /tmp/claude/. Without cleanup these accumulate across sessions. + """ + uid = os.getuid() + tmp_dirs = [ + Path(f"/private/tmp/claude-{uid}"), + Path("/tmp/claude"), + ] + now = datetime.now().timestamp() + max_age_secs = 86400 # 24 hours + cleaned = 0 + + for tmp_dir in tmp_dirs: + if not tmp_dir.exists(): + continue + try: + for item in tmp_dir.iterdir(): + # Skip the tasks/ subdirectory (managed by Claude Code) + if item.name == "tasks" or item.is_dir(): + continue + try: + age = now - item.stat().st_mtime + if age > max_age_secs: + item.unlink() + cleaned += 1 + except OSError: + pass + except OSError: + pass + + if cleaned: + print(f"Session learner: cleaned {cleaned} stale temp files", file=sys.stderr) + + +def main() -> None: + """Main entry point for session learner hook.""" + # Clean up stale temp files from previous sessions + cleanup_temp_storage() + + # Primary: read from daily note (written by session-summary.py) + commits = get_session_log_from_daily() + + # Fallback: read git log directly if daily note unavailable + if commits is None: + commits = get_session_commits() + + if not commits: + sys.exit(0) + + skills = infer_skills_used(commits) + session_type = infer_session_type(commits, skills) + file_counts = count_files_by_type(commits) + + # Triage: classify session importance + triage_level = triage_session(commits, skills) + + # Dismiss sessions don't get recorded (one-off, no learning value) + if triage_level == "dismiss": + print("Session learner: session triaged as dismiss — skipping", file=sys.stderr) + sys.exit(0) + + # Prune old manifests before writing new one + prune_old_manifests() + + # Build entities + session_entity = build_session_entity( + commits, skills, session_type, file_counts, triage_level + ) + skill_entities = build_skill_outcomes(skills) + all_entities = [session_entity] + skill_entities + + # Build relations + relations = [] + for skill_entity in skill_entities: + relations.append( + { + "from": session_entity["name"], + "relationType": "executed", + "to": skill_entity["name"], + } + ) + + # Write manifest for vault-review to process + write_to_memory(all_entities, relations, triage_level) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/session-summary.py b/.claude/hooks/session-summary.py new file mode 100644 index 0000000..5c2533c --- /dev/null +++ b/.claude/hooks/session-summary.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +""" +Session Summary Hook for Claude Code +Appends a git-based session summary to today's daily note. + +Hook Type: Notification (Stop) +Exit Codes: + 0 - Always (non-blocking) +""" + +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +VAULT_ROOT = Path(__file__).resolve().parent.parent.parent +MAX_FILES_SHOWN = 10 + + +def run_git(*args: str) -> str: + """Run a git command and return stdout, or empty string on failure.""" + try: + result = subprocess.run( + ["git", "-C", str(VAULT_ROOT), *args], + capture_output=True, text=True, timeout=5 + ) + return result.stdout.strip() if result.returncode == 0 else "" + except (subprocess.TimeoutExpired, FileNotFoundError): + return "" + + +def get_session_commits() -> list[str]: + """Get commits from the last hour as 'hash message' lines.""" + output = run_git("log", "--oneline", "--since=1 hour ago") + return output.splitlines() if output else [] + + +def get_diff_stat(num_commits: int) -> str: + """Get the --stat summary for the last N commits.""" + if num_commits <= 0: + return "" + return run_git("diff", "--stat", f"HEAD~{num_commits}..HEAD") + + +def parse_diff_stat(stat_output: str) -> tuple[str, list[str]]: + """Parse git diff --stat into a summary line and file list. + + Returns (summary_line, file_list) where summary_line is like + '8 files changed, 217 insertions(+), 12 deletions(-)' and + file_list is the individual file paths. + """ + lines = stat_output.strip().splitlines() + if not lines: + return "", [] + + # Last line is the summary (e.g. "8 files changed, 217 insertions(+)") + summary = lines[-1].strip() + # Earlier lines are file stats (e.g. " path/to/file.md | 4 ++++") + files = [] + for line in lines[:-1]: + parts = line.split("|") + if parts: + filepath = parts[0].strip() + if filepath: + files.append(filepath) + + return summary, files + + +def build_session_entry(commits: list[str], stat_summary: str, file_list: list[str]) -> str: + """Build the markdown session log entry.""" + now = datetime.now() + lines = [f"### {now.strftime('%H:%M:%S')} — Claude Code Session", ""] + + # Commits + lines.append("**Commits:**") + for commit in commits: + lines.append(f"- {commit}") + lines.append("") + + # File changes + if stat_summary: + lines.append(f"**Files changed:** {stat_summary}") + shown = file_list[:MAX_FILES_SHOWN] + for f in shown: + lines.append(f"- {f}") + remaining = len(file_list) - MAX_FILES_SHOWN + if remaining > 0: + lines.append(f"- + {remaining} more") + lines.append("") + + return "\n".join(lines) + + +def get_daily_path() -> Path: + """Return the path to today's daily note.""" + today = datetime.now() + return VAULT_ROOT / "Daily" / str(today.year) / f"{today.strftime('%Y-%m-%d')}.md" + + +def create_daily_note(path: Path) -> str: + """Create a daily note from the template structure.""" + today = datetime.now() + date_str = today.strftime("%Y-%m-%d") + day_name = today.strftime("%A") + day_num = today.day + month_name = today.strftime("%B") + year = today.year + + return f"""--- +type: Daily +title: "{date_str}" +date: "{date_str}" +created: {date_str} +modified: {date_str} +tags: + - daily +relatedTo: [] +--- + +# {day_name}, {day_num} {month_name} {year} + +## Today's Focus + +- + +## Reminders + +## Tasks + +```dataview +TASK +FROM "/" +WHERE !completed AND (doDate = date("{date_str}") OR dueBy = date("{date_str}")) +``` + +## Meetings + +- + +## Notes + +## Session Log + +## Completed Today + +## End of Day Review + +- +""" + + +def insert_session_log(content: str, entry: str) -> str: + """Insert session log entry into the daily note content. + + Three cases: + 1. '## Session Log' exists — append entry after section header (before next ##) + 2. '## End of Day Review' exists but no Session Log — insert Session Log before it + 3. Neither exists — append both sections at the end + """ + session_log_heading = "## Session Log" + eod_heading = "## End of Day Review" + + if session_log_heading in content: + # Find the end of the Session Log section (next ## heading or EOF) + header_pos = content.index(session_log_heading) + after_header = header_pos + len(session_log_heading) + + # Find next ## heading after Session Log + next_heading = re.search(r'\n## ', content[after_header:]) + if next_heading: + insert_pos = after_header + next_heading.start() + else: + insert_pos = len(content) + + # Insert the entry before the next heading (with spacing) + return content[:insert_pos].rstrip() + "\n\n" + entry + "\n" + content[insert_pos:] + + elif eod_heading in content: + # Insert Session Log section before End of Day Review + eod_pos = content.index(eod_heading) + section = f"{session_log_heading}\n\n{entry}\n" + return content[:eod_pos] + section + content[eod_pos:] + + else: + # Append at end + return content.rstrip() + f"\n\n{session_log_heading}\n\n{entry}\n" + + +def append_to_agenda(commits: list[str]) -> None: + """Append a one-liner to AGENDA.md Session Notes section.""" + agenda_path = VAULT_ROOT / ".claude" / "AGENDA.md" + if not agenda_path.exists(): + return + + content = agenda_path.read_text(encoding="utf-8") + + today = datetime.now().strftime("%Y-%m-%d") + summaries = [] + for c in commits[:3]: + parts = c.split(" ", 1) + if len(parts) == 2: + summaries.append(parts[1]) + + summary_text = ", ".join(summaries) + if len(commits) > 3: + summary_text += f" (+{len(commits) - 3} more)" + + entry = f"- **{today}:** {len(commits)} commits — {summary_text}" + + session_notes_heading = "## Session Notes" + if session_notes_heading not in content: + return + + # Find end of Session Notes section + header_pos = content.index(session_notes_heading) + after_header = header_pos + len(session_notes_heading) + + # Append entry at the end of the section (before next ## or EOF) + next_heading = re.search(r'\n## ', content[after_header:]) + if next_heading: + insert_pos = after_header + next_heading.start() + else: + insert_pos = len(content) + + updated = content[:insert_pos].rstrip() + "\n" + entry + "\n" + content[insert_pos:] + agenda_path.write_text(updated, encoding="utf-8") + + +def main(): + # Get recent commits + commits = get_session_commits() + if not commits: + sys.exit(0) + + # Get file change stats + stat_output = get_diff_stat(len(commits)) + stat_summary, file_list = parse_diff_stat(stat_output) + + # Build the entry + entry = build_session_entry(commits, stat_summary, file_list) + + # Get or create daily note + daily_path = get_daily_path() + + if daily_path.exists(): + content = daily_path.read_text(encoding="utf-8") + else: + # Ensure year directory exists + daily_path.parent.mkdir(parents=True, exist_ok=True) + content = create_daily_note(daily_path) + + # Insert the session log entry + updated = insert_session_log(content, entry) + + # Write the file + daily_path.write_text(updated, encoding="utf-8") + + # Append session summary to AGENDA.md + append_to_agenda(commits) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/rules/ambient-memory.md b/.claude/rules/ambient-memory.md index 111d066..0532c5d 100644 --- a/.claude/rules/ambient-memory.md +++ b/.claude/rules/ambient-memory.md @@ -1,8 +1,23 @@ # Ambient Memory -Write to MCP memory **inline** whenever you learn something during a session. Don't wait for session end or for the user to ask. Memory is ambient infrastructure -- every insight is captured the moment it emerges. +Write to MCP memory **inline** whenever you learn something during a session. Don't wait for session end or for the user to ask. Memory is ambient infrastructure — every insight is captured the moment it emerges. -## Three Capture Paths +## Three Memory Systems + +The vault uses three complementary memory systems. Each serves a different purpose — do not duplicate data across them. + +| System | Storage | Purpose | Scope | +|--------|---------|---------|-------| +| **Auto-memory** (built-in) | `~/.claude/projects/*/memory/MEMORY.md` | Free-form session learnings, user preferences, quick patterns | Per-project, shared across worktrees (v2.1.63+) | +| **MCP memory** (this rule) | MCP `memory` server graph | Typed structured entities (`LessonLearned`, `Convention`, `KnowledgeGap`, `PersonInsight`) with relationships | Cross-project, searchable | +| **session-learner.py** | Automated hook | Commit-level data capture | Per-session, automated | + +**When to use which:** +- **Auto-memory** — Quick patterns, user preferences, session context that persists. Managed via `/memory`. Claude writes here automatically. +- **MCP memory** — Structured insights that need entity types, searchability, and promotion paths. Claude writes here via this rule's triggers. +- **session-learner** — Automated. No manual action needed. + +### Capture Paths (MCP Memory) | Path | What | When | |------|------|------| @@ -20,14 +35,31 @@ You MUST write to MCP memory when any of these occur: | Find a workaround | `LessonLearned` | `Lesson-{Slug}` | `Lesson-SandboxBlocksPreCommitCache` | | Learn or establish a convention | `Convention` | `Convention-{Slug}` | `Convention-DailyNoteDateOnly` | | Identify a knowledge gap | `KnowledgeGap` | `Gap-{Slug}` | `Gap-NoMCPMemoryPruning` | -| Learn a person's role or preference | `PersonInsight` | `PersonInsight-{Name}` | `PersonInsight-JaneDoe` | +| Learn a person's role or preference | `PersonInsight` | `PersonInsight-{Name}` | `PersonInsight-TomPhillips` | | Encounter recurring friction | `LessonLearned` | `Lesson-{Slug}` | `Lesson-WorktreePermissions` | +## Entity Type Enforcement + +Only create entities with these exact types (case-sensitive): + +| Type | Casing | Notes | +|------|--------|-------| +| `LessonLearned` | PascalCase | Never `lessonlearned` or `Lessonlearned` | +| `Convention` | PascalCase | Never `convention` (lowercase) | +| `KnowledgeGap` | PascalCase | | +| `Runbook` | PascalCase | | +| `PersonInsight` | PascalCase | | +| `SessionSummary` | PascalCase | Auto-created by session-learner | +| `SkillOutcome` | PascalCase | Auto-created by session-learner | +| `VaultHealth` | PascalCase | Auto-created by session-learner | + +Do NOT create entities with types like `tool`, `concept`, `project-artifact`, or `SessionHistory`. Vault data belongs in the Graph index, not memory. + ## How to Write -1. **Search first** -- `mcp__memory__search_nodes` with the key concept to check for existing entities -2. **If entity exists** -- `mcp__memory__add_observations` to append new observations -3. **If new** -- `mcp__memory__create_entities` with a keyword-searchable name +1. **Search first** — `mcp__memory__search_nodes` with the key concept to check for existing entities +2. **If entity exists** — `mcp__memory__add_observations` to append new observations +3. **If new** — `mcp__memory__create_entities` with a keyword-searchable name ## How to Search @@ -39,23 +71,24 @@ node .claude/scripts/memory-search.js "" --type LessonLearned # Filter by node .claude/scripts/memory-search.js --stats # Entity counts ``` -When MCP `search_nodes` returns no results, always check the archive -- the entity may have been pruned but preserved. +When MCP `search_nodes` returns no results, always check the archive — the entity may have been pruned but preserved. -**Observations must be self-contained** -- readable without session context. Include the date discovered and enough detail to act on later. +**Observations must be self-contained** — readable without session context. Include the date discovered and enough detail to act on later. ## Don't Write -- **Anything from `classification: secret` or `classification: confidential` notes** -- the memory file is unencrypted plain text on disk. Never write observations that reference credentials, API keys, tokens, connection strings, or content from classified notes. +- **Anything from `classification: secret` or `classification: confidential` notes** — the memory file is unencrypted plain text on disk. Never write observations that reference credentials, API keys, tokens, connection strings, or content from classified notes. If a lesson involves a secret-classified note, record the pattern generically without identifying the source or its sensitive content. - Trivial observations (typos, formatting, whitespace fixes) -- Vault entity data (people, systems, projects -- stored in Graph index, not memory) +- Vault entity data (people, systems, projects — stored in Graph index, not memory) - Transient session context that won't matter tomorrow - Anything you already wrote this session (no duplicates) ## Size Management - Practical cap: ~200 entities total -- `SessionSummary` and `SkillOutcome` auto-pruned (keep last 20) -- older ones archived to `Memory/memory-archive.md` -- `LessonLearned`, `Convention`, `KnowledgeGap` are never auto-pruned -- they're the value -- **Promotion** to `.claude/rules/` is the graduation path (3+ recurrences -> durable rule) +- No single entity should have more than 15 observations — condense or split if approaching this limit +- `SessionSummary` and `SkillOutcome` auto-pruned (keep last 20) — older ones archived to `Memory/memory-archive.md` +- `LessonLearned`, `Convention`, `KnowledgeGap` are never auto-pruned — they're the value +- **Promotion** to `.claude/rules/` is the graduation path (3+ recurrences → durable rule) - **Archiving:** When pruning ephemeral entities (SessionSummary, SkillOutcome), write them to `Memory/memory-archive.md` before deletion so history is preserved - If you notice >200 entities during a search, flag it to the user