From a7b5c257661c49f05bb43cf64129fed121b5502a Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 15 Feb 2026 17:51:46 -0600 Subject: [PATCH] Add SSH key instructions and commit-push command - CLAUDE.md: Add SSH section with homelab/cloud key conventions - Add commit-push command skill - Update session memory script Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 5 + commands/commit-push.md | 45 +++ .../session_memory.cpython-314.pyc | Bin 0 -> 31423 bytes scripts/session-memory/session_memory.py | 273 ++++++++++++++---- 4 files changed, 274 insertions(+), 49 deletions(-) create mode 100644 commands/commit-push.md create mode 100644 scripts/session-memory/__pycache__/session_memory.cpython-314.pyc diff --git a/CLAUDE.md b/CLAUDE.md index 47e693e..2f34849 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,11 @@ Automatic loads are NOT enough — Read loads required CLAUDE.md context along t - Utilize dependency injection pattern whenever possible - Never add lazy imports to middle of file +## SSH +- Use `ssh -i ~/.ssh/homelab_rsa cal@` for homelab servers (10.10.0.x) +- Use `ssh -i ~/.ssh/cloud_servers_rsa root@` for cloud servers (Akamai, Vultr) +- Keys are installed on every server — never use passwords or expect password prompts + ## Memory Protocol (Cognitive Memory) - Skill: `~/.claude/skills/cognitive-memory/` | Data: `~/.claude/memory/` - Session start: Load `~/.claude/memory/CORE.md` and `REFLECTION.md` diff --git a/commands/commit-push.md b/commands/commit-push.md new file mode 100644 index 0000000..ae362d2 --- /dev/null +++ b/commands/commit-push.md @@ -0,0 +1,45 @@ +Commit all staged/unstaged changes, push to remote, and optionally create a PR. + +**This command IS explicit approval to commit and push — no need to ask for confirmation.** + +## Arguments: $ARGUMENTS + +If `$ARGUMENTS` contains "pr", also create a pull request after pushing. + +## Steps + +1. Run `git status` to see what has changed (never use `-uall`) +2. Run `git diff` to see staged and unstaged changes +3. Run `git log --oneline -5` to see recent commit style +4. If there are no changes, say "Nothing to commit" and stop +5. Determine the remote name and current branch: + - Remote: use `git remote` (if multiple, prefer `origin`; for `~/.claude` use `homelab`) + - Branch: use `git branch --show-current` +6. Stage all relevant changed files (prefer specific files over `git add -A` — avoid secrets, .env, credentials) +7. Draft a concise commit message following the repo's existing style (focus on "why" not "what") +8. Create the commit with `Co-Authored-By: Claude ` where `` is the model currently in use (check your own model identity — e.g., Opus 4.6, Sonnet 4.5, Haiku 4.5) +9. Push to the remote with `-u` flag: `git push -u ` +10. Confirm success with the commit hash + +## If `pr` argument is present + +After pushing, create a pull request: + +1. Detect the hosting platform: + - If remote URL contains `github.com` → use `gh pr create` + - If remote URL contains `git.manticorum.com` or other Gitea host → use `tea pulls create` +2. Determine the default branch: `git symbolic-ref refs/remotes//HEAD | sed 's|.*/||'` (fallback to `main`) +3. Run `git log ..HEAD --oneline` to summarize all commits in the PR +4. Create the PR: + - **GitHub**: `gh pr create --base --title "Title" --body "..."` + - **Gitea**: `tea pulls create --head --base --title "Title" --description "..."` +5. Include a summary section and test plan in the PR body +6. Return the PR URL + +## Important + +- This command IS explicit approval to commit, push, and (if requested) create a PR +- Do NOT ask for confirmation — the user invoked this command intentionally +- If push fails, show the error and suggest remediation +- Never force push unless the user explicitly says to +- If on `main` branch and `pr` is requested, warn that PRs are typically from feature branches diff --git a/scripts/session-memory/__pycache__/session_memory.cpython-314.pyc b/scripts/session-memory/__pycache__/session_memory.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c40c372f78400c3c4c1cf772b2d7e20898b0e994 GIT binary patch literal 31423 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 aB@-K?eJ-3dl)q!-e|s;Wjj@82{r>{}ZAHHT literal 0 HcmV?d00001 diff --git a/scripts/session-memory/session_memory.py b/scripts/session-memory/session_memory.py index 08128e7..8bc85b5 100755 --- a/scripts/session-memory/session_memory.py +++ b/scripts/session-memory/session_memory.py @@ -11,31 +11,101 @@ import json import re import subprocess import sys +from datetime import datetime from pathlib import Path +LOG_FILE = Path("/tmp/session-memory-hook.log") + + +def log(msg: str): + """Append a timestamped message to the hook log file.""" + with open(LOG_FILE, "a") as f: + f.write(f"{datetime.now().isoformat(timespec='seconds')} {msg}\n") + + +def log_separator(): + """Write a visual separator to the log for readability between sessions.""" + with open(LOG_FILE, "a") as f: + f.write(f"\n{'='*72}\n") + f.write( + f" SESSION MEMORY HOOK — {datetime.now().isoformat(timespec='seconds')}\n" + ) + f.write(f"{'='*72}\n") + def read_stdin(): """Read the hook input JSON from stdin.""" try: - return json.loads(sys.stdin.read()) - except (json.JSONDecodeError, EOFError): + raw = sys.stdin.read() + log(f"[stdin] Raw input length: {len(raw)} chars") + data = json.loads(raw) + log(f"[stdin] Parsed keys: {list(data.keys())}") + return data + except (json.JSONDecodeError, EOFError) as e: + log(f"[stdin] ERROR: Failed to parse input: {e}") return {} def read_transcript(transcript_path: str) -> list[dict]: - """Read JSONL transcript file into a list of message dicts.""" + """Read JSONL transcript file into a list of normalized message dicts. + + Claude Code transcripts use a wrapper format where each line is: + {"type": "user"|"assistant"|..., "message": {"role": ..., "content": ...}, ...} + This function unwraps them into the inner {"role": ..., "content": ...} dicts + that the rest of the code expects. Non-message entries (like file-history-snapshot) + are filtered out. + """ messages = [] path = Path(transcript_path) if not path.exists(): + log(f"[transcript] ERROR: File does not exist: {transcript_path}") return messages + file_size = path.stat().st_size + log(f"[transcript] Reading {transcript_path} ({file_size} bytes)") + parse_errors = 0 + skipped_types = {} + line_num = 0 with open(path) as f: - for line in f: + for line_num, line in enumerate(f, 1): line = line.strip() - if line: - try: - messages.append(json.loads(line)) - except json.JSONDecodeError: - continue + if not line: + continue + try: + raw = json.loads(line) + except json.JSONDecodeError: + parse_errors += 1 + continue + + # Claude Code transcript format: wrapper with "type" and "message" keys + # Unwrap to get the inner message dict with "role" and "content" + if "message" in raw and isinstance(raw["message"], dict): + inner = raw["message"] + # Carry over the wrapper type for logging + wrapper_type = raw.get("type", "unknown") + if "role" not in inner: + inner["role"] = wrapper_type + messages.append(inner) + elif "role" in raw: + # Already in the expected format (future-proofing) + messages.append(raw) + else: + # Non-message entry (file-history-snapshot, etc.) + entry_type = raw.get("type", "unknown") + skipped_types[entry_type] = skipped_types.get(entry_type, 0) + 1 + + if parse_errors: + log(f"[transcript] WARNING: {parse_errors} lines failed to parse") + if skipped_types: + log(f"[transcript] Skipped non-message entries: {skipped_types}") + log(f"[transcript] Loaded {len(messages)} messages from {line_num} lines") + + # Log role breakdown + role_counts = {} + for msg in messages: + role = msg.get("role", "unknown") + role_counts[role] = role_counts.get(role, 0) + 1 + log(f"[transcript] Role breakdown: {role_counts}") + return messages @@ -50,6 +120,7 @@ def find_last_memory_command_index(messages: list[dict]) -> int: Returns -1 if no claude-memory commands were found. """ last_index = -1 + found_commands = [] for i, msg in enumerate(messages): if msg.get("role") != "assistant": continue @@ -66,6 +137,14 @@ def find_last_memory_command_index(messages: list[dict]) -> int: cmd = block.get("input", {}).get("command", "") if "claude-memory" in cmd: last_index = i + found_commands.append(f"msg[{i}]: {cmd[:100]}") + if found_commands: + log(f"[cutoff] Found {len(found_commands)} claude-memory commands:") + for fc in found_commands: + log(f"[cutoff] {fc}") + log(f"[cutoff] Will slice after message index {last_index}") + else: + log("[cutoff] No claude-memory commands found — processing full transcript") return last_index @@ -107,6 +186,14 @@ def extract_tool_uses(messages: list[dict]) -> list[dict]: for block in content: if isinstance(block, dict) and block.get("type") == "tool_use": tool_uses.append(block) + + # Log tool use breakdown + tool_counts = {} + for tu in tool_uses: + name = tu.get("name", "unknown") + tool_counts[name] = tool_counts.get(name, 0) + 1 + log(f"[tools] Extracted {len(tool_uses)} tool uses: {tool_counts}") + return tool_uses @@ -119,6 +206,7 @@ def find_git_commits(tool_uses: list[dict]) -> list[str]: cmd = tu.get("input", {}).get("command", "") if "git commit" in cmd: commits.append(cmd) + log(f"[commits] Found {len(commits)} git commit commands") return commits @@ -131,6 +219,9 @@ def find_files_edited(tool_uses: list[dict]) -> set[str]: fp = tu.get("input", {}).get("file_path", "") if fp: files.add(fp) + log(f"[files] Found {len(files)} edited files:") + for f in sorted(files): + log(f"[files] {f}") return files @@ -150,6 +241,7 @@ def find_errors_encountered(messages: list[dict]) -> list[str]: error_text = extract_text_content({"content": block.get("content", "")}) if error_text and len(error_text) > 10: errors.append(error_text[:500]) + log(f"[errors] Found {len(errors)} error tool results") return errors @@ -168,16 +260,23 @@ def detect_project(cwd: str, files_edited: set[str]) -> str: for path in all_paths: for indicator, project in project_indicators.items(): if indicator in path.lower(): + log( + f"[project] Detected '{project}' from path containing '{indicator}': {path}" + ) return project # Fall back to last directory component of cwd - return Path(cwd).name + fallback = Path(cwd).name + log(f"[project] No indicator matched, falling back to cwd name: {fallback}") + return fallback def build_session_summary(messages: list[dict], cwd: str) -> dict | None: """Analyze the transcript and build a summary of storable events.""" + log(f"[summary] Building summary from {len(messages)} messages, cwd={cwd}") + if len(messages) < 4: - # Too short to be meaningful - return None + log(f"[summary] SKIP: only {len(messages)} messages, need at least 4") + return "too_short" tool_uses = extract_tool_uses(messages) commits = find_git_commits(tool_uses) @@ -194,31 +293,73 @@ def build_session_summary(messages: list[dict], cwd: str) -> dict | None: assistant_texts.append(text) full_assistant_text = "\n".join(assistant_texts) + log( + f"[summary] Assistant text: {len(full_assistant_text)} chars from {len(assistant_texts)} messages" + ) # 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") + keyword_checks = { + "commit": lambda: bool(commits), + "debugging": lambda: bool(errors), + "testing": lambda: any("test" in f.lower() for f in files_edited), + "fix": lambda: any( + kw in full_assistant_text.lower() for kw in ["bug", "fix", "error", "issue"] + ), + "refactoring": lambda: any( + kw in full_assistant_text.lower() + for kw in ["refactor", "restructure", "reorganize"] + ), + "feature": lambda: any( + kw in full_assistant_text.lower() + for kw in ["new feature", "implement", "add support"] + ), + "deployment": lambda: any( + kw in full_assistant_text.lower() + for kw in ["deploy", "production", "release"] + ), + "configuration": lambda: any( + kw in full_assistant_text.lower() + for kw in ["config", "setup", "install", "configure"] + ), + "automation": lambda: any( + kw in full_assistant_text.lower() for kw in ["hook", "script", "automat"] + ), + "tooling": lambda: any( + kw in full_assistant_text.lower() + for kw in [ + "skill", + "command", + "slash command", + "commit-push", + "claude code command", + ] + ), + "creation": lambda: any( + kw in full_assistant_text.lower() + for kw in ["create a ", "created", "new file", "wrote a"] + ), + } + + for work_type, check_fn in keyword_checks.items(): + matched = check_fn() + if matched: + work_types.add(work_type) + log(f"[work_type] MATCH: {work_type}") + else: + log(f"[work_type] no match: {work_type}") if not work_types and not files_edited: - # Likely a research/chat session, skip - return None + log("[summary] SKIP: no work types detected and no files edited") + # Log a snippet of assistant text to help debug missed keywords + snippet = full_assistant_text[:500].replace("\n", " ") + log(f"[summary] Assistant text preview: {snippet}") + return "no_work" + + log( + f"[summary] Result: project={project}, work_types={sorted(work_types)}, " + f"commits={len(commits)}, files={len(files_edited)}, errors={len(errors)}" + ) return { "project": project, @@ -258,7 +399,9 @@ def build_memory_content(summary: dict) -> str: 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") + parts.append( + f"Session size: {summary['message_count']} messages, {summary['tool_use_count']} tool calls" + ) return "\n".join(parts) @@ -276,7 +419,9 @@ def determine_memory_type(summary: dict) -> str: return "code_pattern" if "deployment" in wt: return "workflow" - if "automation" in wt: + if "automation" in wt or "tooling" in wt: + return "workflow" + if "creation" in wt: return "workflow" return "general" @@ -319,55 +464,85 @@ def store_memory(summary: dict): tag_str = ",".join(tags) cmd = [ - "claude-memory", "store", - "--type", mem_type, - "--title", title, - "--content", content, - "--tags", tag_str, - "--importance", importance, + "claude-memory", + "store", + "--type", + mem_type, + "--title", + title, + "--content", + content, + "--tags", + tag_str, + "--importance", + importance, "--episode", ] + log(f"[store] Memory type: {mem_type}, importance: {importance}") + log(f"[store] Title: {title}") + log(f"[store] Tags: {tag_str}") + log(f"[store] Content length: {len(content)} chars") + log(f"[store] Command: {' '.join(cmd)}") + 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) + log(f"[store] SUCCESS: {title}") + if result.stdout.strip(): + log(f"[store] stdout: {result.stdout.strip()[:200]}") else: - print(f"Memory store failed: {result.stderr}", file=sys.stderr) + log(f"[store] FAILED (rc={result.returncode}): {result.stderr.strip()}") + if result.stdout.strip(): + log(f"[store] stdout: {result.stdout.strip()[:200]}") except subprocess.TimeoutExpired: - print("Memory store timed out", file=sys.stderr) + log("[store] FAILED: claude-memory timed out after 10s") + except FileNotFoundError: + log("[store] FAILED: claude-memory command not found in PATH") except Exception as e: - print(f"Memory store error: {e}", file=sys.stderr) + log(f"[store] FAILED: {type(e).__name__}: {e}") def main(): + log_separator() + hook_input = read_stdin() transcript_path = hook_input.get("transcript_path", "") cwd = hook_input.get("cwd", "") + log(f"[main] cwd: {cwd}") + log(f"[main] transcript_path: {transcript_path}") + if not transcript_path: - print("No transcript path provided", file=sys.stderr) + log("[main] ABORT: no transcript path provided") sys.exit(0) messages = read_transcript(transcript_path) if not messages: + log("[main] ABORT: empty transcript") sys.exit(0) + total_messages = len(messages) + # 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:] + messages = messages[cutoff + 1 :] + log(f"[main] After cutoff: {len(messages)} of {total_messages} messages remain") if not messages: - print("No new messages after last claude-memory command", file=sys.stderr) + log("[main] ABORT: no new messages after last claude-memory command") sys.exit(0) + else: + log(f"[main] Processing all {total_messages} messages (no cutoff)") summary = build_session_summary(messages, cwd) - if summary is None: - print("Session too short or no significant work detected", file=sys.stderr) + if not isinstance(summary, dict): + log(f"[main] ABORT: build_session_summary returned '{summary}'") sys.exit(0) store_memory(summary) + log("[main] Done") if __name__ == "__main__":