From 4f87b5c044a58f4407b0d771b4e6fd2a15f5c449 Mon Sep 17 00:00:00 2001 From: AIPass Date: Fri, 13 Mar 2026 18:02:57 -0700 Subject: [PATCH] feat(system): clean hook separation, AIPASS.md as project prompt (#37) - Move general hooks (sounds, auto-fix) to Anthropic settings only - Remove duplicated hooks from project settings (don't fire from subdirs) - Project settings now permissions-only, no hooks - Identity injector: remove apps/ requirement (fixes devpulse detection) - Identity injector: support traits[] array from passport - AIPASS.md: renamed to Project Prompt, silent startup, CWD-relative paths - /memo: read passport first for identity re-injection - README: reflects actual working setup with clear Anthropic/project split Co-authored-by: Claude Opus 4.6 (1M context) --- .claude/README.md | 116 +++++++--------- .claude/commands/memo.md | 7 +- .claude/hooks/auto_fix_diagnostics.py | 193 -------------------------- .claude/hooks/identity_injector.py | 11 +- .claude/hooks/notification_sound.py | 37 ----- .claude/hooks/stop_sound.py | 38 ----- .claude/hooks/tool_use_sound.py | 40 ------ .claude/settings.json | 60 +------- AIPASS.md | 13 +- 9 files changed, 72 insertions(+), 443 deletions(-) delete mode 100644 .claude/hooks/auto_fix_diagnostics.py delete mode 100644 .claude/hooks/notification_sound.py delete mode 100644 .claude/hooks/stop_sound.py delete mode 100644 .claude/hooks/tool_use_sound.py diff --git a/.claude/README.md b/.claude/README.md index cee92a70..c877356e 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -1,83 +1,75 @@ # .claude/ — Claude Code Configuration -This directory configures Claude Code for the AIPass repo. It controls hooks (prompt injection, auto-fix, recovery), custom commands, permissions, and sound effects. +This directory configures Claude Code for the AIPass project. -## How It's Wired +## Settings — Two Files, Clear Separation -There are **two** settings files that matter: +| File | Name | What it does | +|------|------|-------------| +| `~/.claude/settings.json` | **Anthropic settings** | All hooks, sounds, statusline, plugins. Fires everywhere. | +| `.claude/settings.json` (this dir) | **Project settings** | Permissions only. No hooks. | -| File | Scope | Paths | -|------|-------|-------| -| `.claude/settings.json` (this dir) | Project-level, checked into git | Relative paths (e.g. `.claude/hooks/...`) | -| `~/.claude/settings.json` | Global, per-machine | Absolute paths (e.g. `/home/coder/workspace/AIPass/.claude/hooks/...`) | +**Why this split:** Claude Code project settings only fire when launched from the repo root. We launch from branch subdirectories (`src/aipass/{name}/`), so hooks must live in Anthropic settings with absolute paths. -**Why both?** Project settings only fire when Claude launches from the repo root. Since citizens launch from branch subdirectories (`src/aipass/{name}/`), the global config duplicates the hook definitions with absolute paths so they always fire regardless of CWD. +Project settings = permissions. That's it. -After a container rebuild, the global config needs to be restored. The project config is the source of truth (checked into git). +## Hooks — Two Locations, No Duplication + +### Anthropic hooks (`~/.claude/hooks/`) + +General-purpose scripts that work on any project. + +| Script | Event | What it does | +|--------|-------|-------------| +| `tool_use_sound.py` | PreToolUse | Plays keypress sound on tool calls | +| `auto_fix_diagnostics.py` | PostToolUse | Syntax check after file edits | +| `stop_sound.py` | Stop | Sound on stop | +| `notification_sound.py` | Notification | Sound on notification | + +### Project hooks (`AIPass/.claude/hooks/`) + +AIPass-specific scripts. Referenced from Anthropic settings with absolute paths. + +| Script | Event | What it does | +|--------|-------|-------------| +| `branch_prompt_loader.py` | UserPromptSubmit | Finds `.aipass/aipass_local_prompt.md` from CWD, injects branch context | +| `identity_injector.py` | UserPromptSubmit | Reads `.trinity/passport.json`, injects role/traits/purpose | +| `email_notification.py` | UserPromptSubmit | Checks `.ai_mail.local/inbox.json` for unread messages | +| `pre_compact.py` | PreCompact | Saves session context before compaction | + +The global prompt (`aipass_global_prompt.md`) is injected via a `cat` command in Anthropic settings — no script needed. + +## What Gets Injected Every Turn + +1. **Global Prompt** — `cat .aipass/aipass_global_prompt.md` (system context, terminology, rules) +2. **Branch Prompt** — `branch_prompt_loader.py` (branch-specific instructions from CWD) +3. **Identity** — `identity_injector.py` (compact passport summary: role, traits, purpose) +4. **Email** — `email_notification.py` (notification only if unread mail exists) ## Directory Structure ``` .claude/ -├── CLAUDE.md # Project instructions for Claude (auto-loaded) -├── settings.json # Project-level hook config + permissions -├── hooks/ # Hook scripts (Python) -│ ├── branch_prompt_loader.py # Injects branch-local system prompt -│ ├── identity_injector.py # Injects citizen identity from passport -│ ├── email_notification.py # Alerts on unread AI Mail -│ ├── auto_fix_diagnostics.py # Auto-fixes Python/JSON issues after edits -│ ├── pre_compact.py # Saves recovery context before compaction -│ ├── tool_use_sound.py # Sound on tool use (needs audio device) -│ ├── notification_sound.py # Sound on notification (needs audio device) -│ └── stop_sound.py # Sound on stop (needs audio device) -├── commands/ # Custom slash commands -│ └── memo.md # /memo — triggers memory update workflow -└── sounds/ # Audio files for sound hooks - ├── mixkit-achievement-bell-600.wav - ├── mixkit-atm-cash-machine-key-press-2841.wav - └── mixkit-clear-announce-tones-2861.wav +├── settings.json # Permissions only (no hooks) +├── hooks/ # AIPass-specific hook scripts +│ ├── branch_prompt_loader.py +│ ├── identity_injector.py +│ ├── email_notification.py +│ └── pre_compact.py +├── commands/ +│ └── memo.md # /memo — memory update workflow +├── sounds/ # Audio files (symlinked from ~/.claude/sounds) +└── README.md ``` -## Hooks +## Adding a New Hook -### UserPromptSubmit (fires on every prompt) - -1. **Global prompt** — `cat .aipass/aipass_global_prompt.md` — injects system-wide context (terminology, commands, rules) -2. **Branch prompt** — `branch_prompt_loader.py` — finds nearest `.aipass/branch_system_prompt.md` from CWD and injects it -3. **Identity injector** — `identity_injector.py` — reads `.trinity/passport.json` and injects citizen identity (role, purpose, principles) -4. **Email notification** — `email_notification.py` — checks `.ai_mail.local/inbox.json` for unread messages - -### PostToolUse (fires after file edits) - -5. **Auto-fix diagnostics** — `auto_fix_diagnostics.py` — runs Python syntax check and JSON validation on edited files, outputs fix suggestions - -### PreCompact (fires before context compaction) - -6. **Pre-compact recovery** — `pre_compact.py` — saves session context so the citizen can recover after compaction - -### PreToolUse / Stop / Notification (sound hooks) - -7-9. Sound effects — **require audio device** (`--device /dev/snd` in Docker run). Currently non-functional in container. - -## Custom Commands - -- `/memo` — Triggers memory update workflow. Reads `.trinity/` files, updates local.json, observations.json, and optionally passport.json and README.md. +1. Create the script in `.claude/hooks/` +2. Add one entry to `~/.claude/settings.json` (Anthropic settings) with the absolute path +3. Done — no changes to project settings ## Permissions Defined in `settings.json`: - **Denied**: `git reset`, `git rebase`, `git config`, `git push --force`, `EnterPlanMode` - **Default mode**: `acceptEdits` - -## Related Files - -- `.aipass/aipass_global_prompt.md` — The global system prompt (at repo root, NOT in .claude/) -- `src/aipass/{name}/.aipass/branch_system_prompt.md` — Per-branch system prompts -- `src/aipass/{name}/.trinity/passport.json` — Citizen identity (read by identity_injector) -- `src/aipass/{name}/.ai_mail.local/inbox.json` — Mailbox (read by email_notification) - -## After Container Rebuild - -1. `pip install -e . --break-system-packages` -2. Symlink CLI tools: `ln -sf ~/.local/bin/drone /usr/local/bin/drone && ln -sf ~/.local/bin/seedgo /usr/local/bin/seedgo` -3. Copy project settings to global: restore `~/.claude/settings.json` with absolute paths (see global config format above) diff --git a/.claude/commands/memo.md b/.claude/commands/memo.md index 213fbcdb..80ecd5d2 100644 --- a/.claude/commands/memo.md +++ b/.claude/commands/memo.md @@ -4,9 +4,10 @@ Purpose: Update branch memory files after completing work this session. ## Execution -1. Review what was done this session (context, recent changes, key decisions) -2. Update each file below as needed -3. Confirm completion — list files updated +1. Read `.trinity/passport.json` first — re-absorb your identity, role, and principles before writing memories +2. Review what was done this session (context, recent changes, key decisions) +3. Update each file below as needed +4. Confirm completion — list files updated ## What to Update diff --git a/.claude/hooks/auto_fix_diagnostics.py b/.claude/hooks/auto_fix_diagnostics.py deleted file mode 100644 index 17698115..00000000 --- a/.claude/hooks/auto_fix_diagnostics.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python3 -""" -Silent Auto-fix Hook - Runs validation and tells Claude to fix silently. - -Runs real linters (ruff, py_compile), validates JSON, checks Python patterns. -Outputs via additionalContext so Claude sees errors and fixes them without -announcing. - -Version: 1.0.0 -""" - -import json -import sys -import subprocess -from pathlib import Path - -EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"] -LAST_FILE_PATH = Path(__file__).parent / ".last_diagnostics_file" -SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"} - -PYTHON_PATTERNS = { - "bad_optional": { - "pattern": ": str = None", - "message": "Optional param should use 'str | None = None' pattern", - }, - "open_no_encoding": { - "pattern": "open(", - "requires_missing": "encoding=", - "message": "open() without encoding='utf-8'", - }, -} - -JSON_CORRUPTION_CHARS = ["\ufffd", "\x00"] - - -def run_python_checks(file_path: str) -> list[str]: - """Run actual Python validation.""" - errors = [] - - try: - result = subprocess.run( - [sys.executable, "-m", "py_compile", file_path], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode != 0: - errors.append(f"SYNTAX: {result.stderr.strip()}") - except Exception: - pass - - try: - result = subprocess.run( - ["ruff", "check", "--select=E,F,W", "--output-format=text", file_path], - capture_output=True, - text=True, - timeout=10, - ) - if result.stdout.strip(): - for line in result.stdout.strip().split("\n")[:5]: - errors.append(f"LINT: {line}") - except FileNotFoundError: - pass - except Exception: - pass - - try: - content = Path(file_path).read_text(encoding="utf-8") - for check in PYTHON_PATTERNS.values(): - pattern = check["pattern"] - message = check["message"] - requires_missing = check.get("requires_missing") - - if requires_missing: - if pattern in content and requires_missing not in content: - errors.append(f"PATTERN: {message}") - continue - - for line in content.split("\n"): - stripped = line.strip() - if stripped.startswith(("#", '"', "'")): - continue - if f'"{pattern}' in line or f"'{pattern}" in line: - continue - if pattern in line: - errors.append(f"PATTERN: {message}") - break - except Exception: - pass - - return errors - - -def run_json_checks(file_path: str) -> list[str]: - """Run actual JSON validation.""" - errors = [] - - try: - content = Path(file_path).read_text(encoding="utf-8") - - for char in JSON_CORRUPTION_CHARS: - if char in content: - errors.append( - f"CORRUPTION: Found corrupted character '{repr(char)}'" - ) - break - - try: - json.loads(content) - except json.JSONDecodeError as e: - errors.append(f"JSON SYNTAX: {e.msg} at line {e.lineno}") - - except Exception as e: - errors.append(f"READ ERROR: {e!s}") - - return errors - - -def should_skip_file(file_path: str) -> bool: - """Check if file should be skipped.""" - if not file_path: - return True - ext = Path(file_path).suffix.lower() - return ext in SKIP_EXTENSIONS - - -def is_same_file_as_last(file_path: str) -> bool: - """Smart batching - skip if same file as last check.""" - try: - if LAST_FILE_PATH.exists(): - last = LAST_FILE_PATH.read_text(encoding="utf-8").strip() - if last == file_path: - return True - LAST_FILE_PATH.write_text(file_path, encoding="utf-8") - except Exception: - pass - return False - - -def main(): - """Main hook entry point.""" - try: - input_data = json.load(sys.stdin) - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - file_path = tool_input.get("file_path", "") - - if tool_name not in EDIT_TOOLS: - return - - if should_skip_file(file_path): - return - - if is_same_file_as_last(file_path): - return - - errors = [] - file_type = "" - - if file_path.endswith(".py"): - file_type = "Python" - errors = run_python_checks(file_path) - elif file_path.endswith(".json"): - file_type = "JSON" - errors = run_json_checks(file_path) - else: - return - - if errors: - error_text = "\n".join(f" - {e}" for e in errors) - context = f"""[SILENT-FIX] {file_type} errors in {Path(file_path).name}: -{error_text} - -Fix these issues silently. Do not announce or explain fixes - just fix and continue.""" - - output = { - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": context, - }, - "systemMessage": f"[diagnostics] {len(errors)} issue(s)", - } - print(json.dumps(output)) - else: - output = {"systemMessage": "[diagnostics] ok"} - print(json.dumps(output)) - - except Exception: - pass - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/identity_injector.py b/.claude/hooks/identity_injector.py index ee67e5aa..f6c7c8bb 100644 --- a/.claude/hooks/identity_injector.py +++ b/.claude/hooks/identity_injector.py @@ -30,11 +30,10 @@ def find_branch_root() -> Path | None: search_path = cwd while search_path >= repo_root: - has_apps = (search_path / "apps").is_dir() has_trinity = (search_path / ".trinity").is_dir() has_id = list(search_path.glob("*.id.json")) - if has_apps and (has_trinity or has_id): + if has_trinity or has_id: return search_path if search_path == repo_root: @@ -70,8 +69,12 @@ def format_identity(data: dict) -> str: identity = data.get("identity", {}) if identity.get("role"): lines.append(f"Role: {identity['role']}") - if identity.get("traits"): - lines.append(f"Traits: {identity['traits']}") + traits = identity.get("traits") or data.get("traits") + if traits: + if isinstance(traits, list): + lines.append("Traits: " + " | ".join(traits)) + else: + lines.append(f"Traits: {traits}") if identity.get("purpose"): lines.append(f"Purpose: {identity['purpose']}") diff --git a/.claude/hooks/notification_sound.py b/.claude/hooks/notification_sound.py deleted file mode 100644 index 3492e707..00000000 --- a/.claude/hooks/notification_sound.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -"""Notification Hook — Plays sound when AI needs permission.""" - -import json -import sys -import subprocess -from pathlib import Path - -SOUNDS_DIR = Path(__file__).parent.parent / "sounds" -SOUND_FILE = SOUNDS_DIR / "mixkit-clear-announce-tones-2861.wav" - - -def play_sound() -> None: - if not SOUND_FILE.exists(): - return - try: - subprocess.Popen( - ["aplay", "-q", str(SOUND_FILE)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception: - pass - - -def main(): - try: - hook_data = json.loads(sys.stdin.read()) - if hook_data.get("hook_event_name") == "Notification": - play_sound() - except Exception: - pass - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/stop_sound.py b/.claude/hooks/stop_sound.py deleted file mode 100644 index 59236ff6..00000000 --- a/.claude/hooks/stop_sound.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -"""Stop Hook — Plays achievement bell when AI finishes responding.""" - -import json -import sys -import subprocess -from pathlib import Path - -SOUNDS_DIR = Path(__file__).parent.parent / "sounds" -SOUND_FILE = SOUNDS_DIR / "mixkit-achievement-bell-600.wav" - - -def play_sound() -> None: - if not SOUND_FILE.exists(): - return - try: - subprocess.Popen( - ["aplay", "-q", str(SOUND_FILE)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception: - pass - - -def main(): - try: - hook_data = json.loads(sys.stdin.read()) - if hook_data.get("hook_event_name") == "Stop": - if not hook_data.get("stop_hook_active", False): - play_sound() - except Exception: - pass - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/tool_use_sound.py b/.claude/hooks/tool_use_sound.py deleted file mode 100644 index fe09c5b3..00000000 --- a/.claude/hooks/tool_use_sound.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -"""Tool Use Hook — Plays key press sound when AI uses tools.""" - -import json -import sys -import subprocess -from pathlib import Path - -SOUNDS_DIR = Path(__file__).parent.parent / "sounds" -SOUND_FILE = SOUNDS_DIR / "mixkit-atm-cash-machine-key-press-2841.wav" - -SOUND_TOOLS = ["Bash", "Edit", "MultiEdit", "Write", "Read", "Grep", "Glob"] - - -def play_sound() -> None: - if not SOUND_FILE.exists(): - return - try: - subprocess.Popen( - ["aplay", "-q", str(SOUND_FILE)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception: - pass - - -def main(): - try: - hook_data = json.loads(sys.stdin.read()) - if hook_data.get("hook_event_name") == "PreToolUse": - if hook_data.get("tool_name", "") in SOUND_TOOLS: - play_sound() - except Exception: - pass - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.claude/settings.json b/.claude/settings.json index 44853105..bc2214f4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,63 +10,5 @@ ], "defaultMode": "acceptEdits" }, - "skipDangerousModePermissionPrompt": true, - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "/home/patrick/.claude/hook_logger.sh global_prompt && cat /home/patrick/Projects/AIPass/.aipass/aipass_global_prompt.md 2>/dev/null || true" - } - ] - }, - { - "hooks": [ - { - "type": "command", - "command": "/home/patrick/.claude/hook_logger.sh branch_prompt && python3 /home/patrick/Projects/AIPass/.claude/hooks/branch_prompt_loader.py" - } - ] - }, - { - "hooks": [ - { - "type": "command", - "command": "/home/patrick/.claude/hook_logger.sh identity && python3 /home/patrick/Projects/AIPass/.claude/hooks/identity_injector.py" - } - ] - }, - { - "hooks": [ - { - "type": "command", - "command": "/home/patrick/.claude/hook_logger.sh email_check && python3 /home/patrick/Projects/AIPass/.claude/hooks/email_notification.py" - } - ] - } - ], - "PreCompact": [ - { - "matcher": "manual", - "hooks": [ - { - "type": "command", - "command": "/home/patrick/.claude/hook_logger.sh pre_compact && python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact.py", - "timeout": 60 - } - ] - }, - { - "matcher": "auto", - "hooks": [ - { - "type": "command", - "command": "/home/patrick/.claude/hook_logger.sh pre_compact && python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact.py", - "timeout": 60 - } - ] - } - ] - } + "skipDangerousModePermissionPrompt": true } diff --git a/AIPASS.md b/AIPASS.md index 32ead113..e8e38a2a 100644 --- a/AIPASS.md +++ b/AIPASS.md @@ -1,15 +1,14 @@ -/home/patrick/Projects/AIPass/CLAUDE.md +# AIPass — Project Prompt -# AIPass — System Prompt - -Injected every turn. Keep this lean — details live in README, --help, and .trinity/ memories. +Project-level instructions loaded by Claude Code. Persists in context for the entire conversation. +Details live in README, --help, .trinity/ memories, and the Global Prompt (.aipass/aipass_global_prompt.md). ## Startup -Greetings (`hi`, `hello`, `yo`, `hey`, `sup`, `good morning`, `good evening`, `what's up`) trigger startup protocol. Everything else is a direct task. +On any greeting, silently read these files from CWD and run the commands — no narration, no announcing steps. Just do it and respond with the status. -**On startup, read:** `.trinity/passport.json`, `local.json`, `observations.json`, `DASHBOARD.local.json`, `dev.local.md`, `README..md` -**Then run:** `git status`, `drone systems` +**Read:** `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `STATUS.local.md`, `README.md` +**Run:** `git status`, `drone systems` ## Navigation