diff --git a/CLAUDE.md b/CLAUDE.md index 0fd78ce..a78f050 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,160 +1,32 @@ # 🚨 CRITICAL: @ MENTION HANDLING 🚨 -When ANY file is mentioned with @ syntax, you MUST IMMEDIATELY call Read tool on that file BEFORE responding. -You will see automatic loads of any @ mentioned filed, this is NOT ENOUGH, it only loads the file contents. -You MUST perform Read tool calls on the files directly, even if they were @ included. -This is NOT optional - it loads required CLAUDE.md context. along the file path. +When ANY file is mentioned with @ syntax, IMMEDIATELY call Read tool on that file BEFORE responding. +Automatic loads are NOT enough — Read loads required CLAUDE.md context along the file path. -## Confidently Incorrect Is Worst-Case Scenario -- If the user asks a question and you are not very confident in your answer, tell the user that you are not sure -- When this happens, offer your current hypothesis (if you have one) and then offer options you can take to find the answer +## Behavior +- User's name is Cal (he/him) +- If not confident in an answer, say so. Offer hypothesis + options to investigate. +- When writing tests, include detailed docstrings explaining "what" and "why" -## Basics - -- User's name is Cal and uses he/him pronouns -- When writing tests, always include a detailed docstring explaining the "what" and "why" of the test. -- DO NOT COMMIT CODE WITHOUT APPROVAL FROM THE USER - -### CRITICAL: Git Commit Approval Checkpoint - -**Before EVERY `git commit` or `git add` command, STOP and verify:** - -1. **Did the user EXPLICITLY approve this commit?** - - ✅ Approval: "commit this", "deploy it", "go ahead", "push to production" - - ❌ NOT approval: Technical comments ("--yes flag"), silence after showing fix, user frustration, ambiguous signals - -2. **If NO explicit approval:** - - STOP immediately - - ASK: "Should I commit and deploy this fix?" - - WAIT for clear response - -3. **Common failure pattern:** - - Going into "fix mode" autopilot: find bug → fix → commit → deploy (WRONG) - - Correct flow: find bug → fix → **ASK** → get approval → commit → deploy - -**This applies to ALL git operations including:** -- `git commit` -- `git add` (staging for commit) -- `git tag` -- `git push` -- Any deployment scripts that commit internally +## Git Commits +- NEVER commit/add/push/tag without explicit user approval ("commit this", "go ahead") +- Don't autopilot: find bug → fix → **ASK** → commit. Silence ≠ approval. +- Applies to: git commit, git add, git tag, git push, deploy scripts ## Gitea Operations - -**ALWAYS use `tea` CLI for Gitea.** Never use `gh api --hostname` — it's not compatible with Gitea's API. - +**ALWAYS use `tea` CLI for Gitea.** Never use `gh api --hostname`. - Authenticated: `cal@homelab` (https://git.manticorum.com) -- Common: `tea repos list`, `tea pulls list`, `tea issues list`, `tea pulls create` - -### Create PR (triggers Gitea Actions) - -```bash -# From within repo directory -tea pulls create --head --base main --title "Title" --description "Description" - -# Or specify repo from anywhere -tea pulls create --repo cal/major-domo-database --head fix/feature --base main --title "Title" --description "Description" - -# With labels, assignees, milestone -tea pulls create --head fix/feature --base main --title "Title" --labels bug,priority --assignees cal --milestone v1.2.0 -``` - -**Common repos:** cal/major-domo-database, cal/major-domo-bot, cal/paper-dynasty, cal/paper-dynasty-database +- Common: `tea repos list`, `tea pulls list`, `tea issues list` +- Create PR: `tea pulls create --head --base main --title "Title" --description "Desc"` +- Common repos: cal/major-domo-database, cal/major-domo-bot, cal/paper-dynasty, cal/paper-dynasty-database ## Tech Preferences +- Python with uv for package/environment management +- Never add lazy imports to middle of file -- Preferred language: Python with uv for package and environment management -- Specific code requirements: Never add lazy imports to middle of file - - ## Memory Protocol (Cognitive Memory) - -Cognitive Memory provides persistent, human-readable markdown-based memory across all sessions. Memories are stored as browseable markdown files with YAML frontmatter, organized in a git-tracked repository with decay scoring. - -**Skill Location:** `~/.claude/skills/cognitive-memory/` -**Data Directory:** `~/.claude/memory/` -**CORE.md:** `~/.claude/memory/CORE.md` (auto-curated summary, load at session start) -**REFLECTION.md:** `~/.claude/memory/REFLECTION.md` (theme analysis and cross-project patterns) -**Documentation:** `~/.claude/skills/cognitive-memory/SKILL.md` - -### REQUIRED: Session Start -1. Load CORE.md context: `~/.claude/memory/CORE.md` -2. Load REFLECTION.md context: `~/.claude/memory/REFLECTION.md` -3. Recall relevant memories before any significant task: - -```bash -claude-memory recall "project-name technology problem-type" -``` - -### REQUIRED: Automatic Storage Triggers -Store memories on ANY of these events: - -| Trigger | What to Store | -|---------|---------------| -| **Bug fix** | Problem + solution with SOLVES relationship | -| **Git commit** | **MANDATORY** — store memory immediately after every successful commit with full context (reasoning, trade-offs, debugging steps), not just the commit message. Use `--episode` flag. Never skip. | -| **Architecture decision** | Choice made + rationale + alternatives | -| **Pattern discovered** | Reusable approach with context | -| **Configuration that worked** | Setup details that solved an issue | -| **Troubleshooting session** | Steps taken, what worked, what didn't | -| **Session milestone** | Log episode entry for chronological context | - -### CLI Usage - -All commands support `--help`. Key patterns: - -```bash -# Core workflow -claude-memory store --type solution --title "Fixed X" --content "..." --tags "t1,t2" --episode -claude-memory recall "redis timeout" --semantic - -# Relationships and search -claude-memory relate SOLVES -claude-memory search --types "solution" --tags "python" - -# Procedures, reflection, maintenance -claude-memory procedure --title "Deploy" --content "..." --steps "test,build,deploy" -claude-memory reflect --since 2026-01-01 -claude-memory decay && claude-memory core && claude-memory embed -``` - -### Memory Types -`solution` | `problem` | `error` | `fix` | `decision` | `code_pattern` | `configuration` | `workflow` | `general` | `procedure` | `insight` - -### Importance Scale -- `0.8-1.0`: Critical - affects multiple projects or prevents major issues -- `0.5-0.7`: Standard - useful pattern or solution -- `0.3-0.4`: Minor - nice-to-know, edge case - -### Tag Requirements (ALWAYS include) -1. Project name (paper-dynasty, major-domo, homelab, etc.) -2. Primary technology (python, docker, proxmox, bash, etc.) -3. Category (fix, pattern, decision, config, troubleshooting) - -### Relationship Types -- `SOLVES` - Solution addresses a problem -- `CAUSES` - One issue leads to another -- `BUILDS_ON` - Enhancement to existing pattern -- `ALTERNATIVE_TO` - Different approach to same problem -- `REQUIRES` - Dependency relationship -- `FOLLOWS` - Workflow sequence -- `RELATED_TO` - General association - -### Proactive Memory Usage - -**At session start:** Load CORE.md and REFLECTION.md, then recall relevant memories for current project. - -**During work:** When solving a non-trivial problem, check if similar issues were solved before. Use `--semantic` for deeper matching. - -**After milestones:** Use `--episode` flag on store to auto-log episode entries. Or log manually with `episode` command. - -**Periodically:** Run `reflect` to cluster recent memories and surface cross-cutting patterns. Check `tags suggest` for missing tag connections. - -**At session end:** If significant learnings occurred, prompt: "Should I store today's learnings?" - -### Deprecated: MemoryGraph (Legacy) - -MemoryGraph (SQLite-based) has been migrated to Cognitive Memory. The old database is archived at `~/.memorygraph/memory.db.archive`. The old client at `~/.claude/skills/memorygraph/client.py` still works for read-only access to the archive if needed. - -## Project Planning - -Use `/project-plan` to generate structured `PROJECT_PLAN.json` files for tracking refactoring, features, migrations, or audits. See `~/.claude/skills/project-plan/SKILL.md` for full schema and usage. +- Skill: `~/.claude/skills/cognitive-memory/` | Data: `~/.claude/memory/` +- Session start: Load `~/.claude/memory/CORE.md` and `REFLECTION.md` +- Auto-store on: bug fixes, git commits (mandatory, --episode), architecture decisions, patterns, configs +- Always tag: project name + technology + category +- Session end: prompt "Should I store today's learnings?" +- Full docs: `claude-memory --help` or `~/.claude/skills/cognitive-memory/SKILL.md` diff --git a/scripts/session-memory/session_memory.py b/scripts/session-memory/session_memory.py new file mode 100755 index 0000000..08128e7 --- /dev/null +++ b/scripts/session-memory/session_memory.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +Session-end memory hook for Claude Code. + +Reads the session transcript, extracts significant events (commits, bug fixes, +architecture decisions, new patterns, configurations), and stores them as +cognitive memories via claude-memory CLI. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + + +def read_stdin(): + """Read the hook input JSON from stdin.""" + try: + return json.loads(sys.stdin.read()) + except (json.JSONDecodeError, EOFError): + return {} + + +def read_transcript(transcript_path: str) -> list[dict]: + """Read JSONL transcript file into a list of message dicts.""" + messages = [] + path = Path(transcript_path) + if not path.exists(): + return messages + with open(path) as f: + for line in f: + line = line.strip() + if line: + try: + messages.append(json.loads(line)) + except json.JSONDecodeError: + continue + return messages + + +def find_last_memory_command_index(messages: list[dict]) -> int: + """Find the index of the last message containing a claude-memory command. + + Scans for Bash tool_use blocks where the command contains 'claude-memory' + (store, recall, episode, etc). Returns the index of that message so we can + slice the transcript to only process messages after the last memory operation, + avoiding duplicate storage. + + Returns -1 if no claude-memory commands were found. + """ + last_index = -1 + for i, msg in enumerate(messages): + if msg.get("role") != "assistant": + continue + content = msg.get("content", []) + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") != "tool_use": + continue + if block.get("name") != "Bash": + continue + cmd = block.get("input", {}).get("command", "") + if "claude-memory" in cmd: + last_index = i + return last_index + + +def extract_text_content(message: dict) -> str: + """Extract plain text from a message's content blocks.""" + content = message.get("content", "") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, dict): + if block.get("type") == "text": + parts.append(block.get("text", "")) + elif block.get("type") == "tool_result": + # Recurse into tool result content + sub = block.get("content", "") + if isinstance(sub, str): + parts.append(sub) + elif isinstance(sub, list): + for sb in sub: + if isinstance(sb, dict) and sb.get("type") == "text": + parts.append(sb.get("text", "")) + elif isinstance(block, str): + parts.append(block) + return "\n".join(parts) + return "" + + +def extract_tool_uses(messages: list[dict]) -> list[dict]: + """Extract all tool_use blocks from assistant messages.""" + tool_uses = [] + for msg in messages: + if msg.get("role") != "assistant": + continue + content = msg.get("content", []) + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + tool_uses.append(block) + return tool_uses + + +def find_git_commits(tool_uses: list[dict]) -> list[str]: + """Find git commit commands from Bash tool uses.""" + commits = [] + for tu in tool_uses: + if tu.get("name") != "Bash": + continue + cmd = tu.get("input", {}).get("command", "") + if "git commit" in cmd: + commits.append(cmd) + return commits + + +def find_files_edited(tool_uses: list[dict]) -> set[str]: + """Find unique files edited via Edit/Write tools.""" + files = set() + for tu in tool_uses: + name = tu.get("name", "") + if name in ("Edit", "Write", "MultiEdit"): + fp = tu.get("input", {}).get("file_path", "") + if fp: + files.add(fp) + return files + + +def find_errors_encountered(messages: list[dict]) -> list[str]: + """Find error messages from tool results.""" + errors = [] + for msg in messages: + if msg.get("role") != "user": + continue + content = msg.get("content", []) + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "tool_result" and block.get("is_error"): + error_text = extract_text_content({"content": block.get("content", "")}) + if error_text and len(error_text) > 10: + errors.append(error_text[:500]) + return errors + + +def detect_project(cwd: str, files_edited: set[str]) -> str: + """Detect project name from cwd and edited files.""" + all_paths = [cwd] + list(files_edited) + project_indicators = { + "major-domo": "major-domo", + "paper-dynasty": "paper-dynasty", + "claude-home": "homelab", + "homelab": "homelab", + ".claude": "claude-config", + "openclaw": "openclaw", + "tdarr": "tdarr", + } + for path in all_paths: + for indicator, project in project_indicators.items(): + if indicator in path.lower(): + return project + # Fall back to last directory component of cwd + return Path(cwd).name + + +def build_session_summary(messages: list[dict], cwd: str) -> dict | None: + """Analyze the transcript and build a summary of storable events.""" + if len(messages) < 4: + # Too short to be meaningful + return None + + tool_uses = extract_tool_uses(messages) + commits = find_git_commits(tool_uses) + files_edited = find_files_edited(tool_uses) + errors = find_errors_encountered(messages) + project = detect_project(cwd, files_edited) + + # Collect assistant text for topic extraction + assistant_texts = [] + for msg in messages: + if msg.get("role") == "assistant": + text = extract_text_content(msg) + if text: + assistant_texts.append(text) + + full_assistant_text = "\n".join(assistant_texts) + + # Detect what kind of work was done + work_types = set() + if commits: + work_types.add("commit") + if errors: + work_types.add("debugging") + if any("test" in f.lower() for f in files_edited): + work_types.add("testing") + if any(kw in full_assistant_text.lower() for kw in ["bug", "fix", "error", "issue"]): + work_types.add("fix") + if any(kw in full_assistant_text.lower() for kw in ["refactor", "restructure", "reorganize"]): + work_types.add("refactoring") + if any(kw in full_assistant_text.lower() for kw in ["new feature", "implement", "add support"]): + work_types.add("feature") + if any(kw in full_assistant_text.lower() for kw in ["deploy", "production", "release"]): + work_types.add("deployment") + if any(kw in full_assistant_text.lower() for kw in ["config", "setup", "install", "configure"]): + work_types.add("configuration") + if any(kw in full_assistant_text.lower() for kw in ["hook", "script", "automat"]): + work_types.add("automation") + + if not work_types and not files_edited: + # Likely a research/chat session, skip + return None + + return { + "project": project, + "work_types": work_types, + "commits": commits, + "files_edited": sorted(files_edited), + "errors": errors[:5], # Cap at 5 + "assistant_text_snippet": full_assistant_text[:3000], + "message_count": len(messages), + "tool_use_count": len(tool_uses), + } + + +def build_memory_content(summary: dict) -> str: + """Build a concise memory content string from the summary.""" + parts = [] + + if summary["commits"]: + parts.append(f"Commits made: {len(summary['commits'])}") + for c in summary["commits"][:3]: + # Extract commit message + match = re.search(r'-m\s+["\'](.+?)["\']', c) + if not match: + match = re.search(r"<<'?EOF'?\n(.+?)(?:\n|EOF)", c, re.DOTALL) + if match: + parts.append(f" - {match.group(1)[:200]}") + + if summary["files_edited"]: + parts.append(f"Files edited ({len(summary['files_edited'])}):") + for f in summary["files_edited"][:10]: + parts.append(f" - {f}") + + if summary["errors"]: + parts.append(f"Errors encountered ({len(summary['errors'])}):") + for e in summary["errors"][:3]: + parts.append(f" - {e[:150]}") + + work_desc = ", ".join(sorted(summary["work_types"])) + parts.append(f"Work types: {work_desc}") + parts.append(f"Session size: {summary['message_count']} messages, {summary['tool_use_count']} tool calls") + + return "\n".join(parts) + + +def determine_memory_type(summary: dict) -> str: + """Pick the best memory type based on work done.""" + wt = summary["work_types"] + if "fix" in wt or "debugging" in wt: + return "fix" + if "configuration" in wt: + return "configuration" + if "feature" in wt: + return "workflow" + if "refactoring" in wt: + return "code_pattern" + if "deployment" in wt: + return "workflow" + if "automation" in wt: + return "workflow" + return "general" + + +def build_title(summary: dict) -> str: + """Generate a descriptive title for the memory.""" + project = summary["project"] + work = ", ".join(sorted(summary["work_types"])) + if summary["commits"]: + # Try to use first commit message as title basis + first_commit = summary["commits"][0] + match = re.search(r'-m\s+["\'](.+?)["\']', first_commit) + if not match: + match = re.search(r"<<'?EOF'?\n(.+?)(?:\n|EOF)", first_commit, re.DOTALL) + if match: + msg = match.group(1).split("\n")[0][:80] + return f"[{project}] {msg}" + return f"[{project}] Session: {work}" + + +def store_memory(summary: dict): + """Store the session memory via claude-memory CLI.""" + title = build_title(summary) + content = build_memory_content(summary) + mem_type = determine_memory_type(summary) + importance = "0.4" + + # Boost importance for commits or significant work + if summary["commits"]: + importance = "0.6" + if len(summary["files_edited"]) > 5: + importance = "0.6" + if "deployment" in summary["work_types"]: + importance = "0.7" + + # Build tags + tags = [summary["project"]] + tags.extend(sorted(summary["work_types"])) + tags.append("session-log") + tag_str = ",".join(tags) + + cmd = [ + "claude-memory", "store", + "--type", mem_type, + "--title", title, + "--content", content, + "--tags", tag_str, + "--importance", importance, + "--episode", + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if result.returncode == 0: + print(f"Session memory stored: {title}", file=sys.stderr) + else: + print(f"Memory store failed: {result.stderr}", file=sys.stderr) + except subprocess.TimeoutExpired: + print("Memory store timed out", file=sys.stderr) + except Exception as e: + print(f"Memory store error: {e}", file=sys.stderr) + + +def main(): + hook_input = read_stdin() + transcript_path = hook_input.get("transcript_path", "") + cwd = hook_input.get("cwd", "") + + if not transcript_path: + print("No transcript path provided", file=sys.stderr) + sys.exit(0) + + messages = read_transcript(transcript_path) + if not messages: + sys.exit(0) + + # Only process messages after the last claude-memory command to avoid + # duplicating memories that were already stored during the session. + cutoff = find_last_memory_command_index(messages) + if cutoff >= 0: + messages = messages[cutoff + 1:] + if not messages: + print("No new messages after last claude-memory command", file=sys.stderr) + sys.exit(0) + + summary = build_session_summary(messages, cwd) + if summary is None: + print("Session too short or no significant work detected", file=sys.stderr) + sys.exit(0) + + store_memory(summary) + + +if __name__ == "__main__": + main() diff --git a/settings.json b/settings.json index 65f2a59..4468ec0 100644 --- a/settings.json +++ b/settings.json @@ -94,6 +94,7 @@ "/mnt/NV2/SteamLibrary/" ] }, + "model": "opus", "enableAllProjectMcpServers": false, "enabledMcpjsonServers": [], "statusLine": { @@ -101,5 +102,17 @@ "command": "input=$(cat); cwd=$(echo \"$input\" | jq -r '.workspace.current_dir // .cwd'); display_cwd=$(echo \"$cwd\" | sed \"s|^$HOME|~|\"); if git -C \"$cwd\" rev-parse --git-dir >/dev/null 2>&1; then branch=$(git -C \"$cwd\" branch --show-current 2>/dev/null); [ -n \"$branch\" ] && git_info=\" \\033[33m($branch)\\033[0m\" || git_info=\"\"; else git_info=\"\"; fi; pulse=$(/usr/bin/python \"/home/cal/.claude/scripts/claude-pulse/claude_status.py\" <<< \"$input\"); printf \"\\033[34m%s\\033[0m%b\\n\" \"$display_cwd\" \"$git_info\"; [ -n \"$pulse\" ] && printf \"%s\" \"$pulse\"; printf \"\\n\"" }, "effortLevel": "medium", - "model": "sonnet" + "hooks": { + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/bin/python3 /home/cal/.claude/scripts/session-memory/session_memory.py", + "timeout": 15 + } + ] + } + ] + } }