diff --git a/.claude/README.md b/.claude/README.md index 0184a231..3a9e2be0 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -2,96 +2,146 @@ This directory configures Claude Code for the AIPass project. -## Settings — Two Files, Clear Separation +**Related:** DPLAN-0053 (Hook Migration) documents the research and decisions behind this architecture. -| 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, env vars, project-scoped hooks. | +## Quick Setup -**Why this split:** Claude Code project settings only fire when launched from the repo root. We launch from branch subdirectories (`src/aipass/{name}/`), so most hooks live in Anthropic settings with absolute paths. Project settings handles permissions, environment configuration, and hooks that need project-level scoping (like SubagentStop). +AIPass hooks live in two places. The project hooks (`hooks/`) travel with the repo. The global hooks (`global_hooks/`) need to be copied to your `~/.claude/` directory. -## Environment +### Step 1: Copy global hooks -Project settings sets `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` — this makes PostToolUse hooks (like auto-fix) fire inside subagents, not just the parent conversation. Without this, subagents bypass all hook enforcement. +```bash +# Copy hook scripts to your Anthropic hooks directory +mkdir -p ~/.claude/hooks +cp .claude/global_hooks/*.py ~/.claude/hooks/ +cp .claude/global_hooks/*.sh ~/.claude/ -## Hooks — Two Locations, No Duplication +# Optional: copy sounds (if you want audio feedback) +mkdir -p ~/.claude/sounds +cp .claude/sounds/* ~/.claude/sounds/ 2>/dev/null || true +``` -### Anthropic hooks (`~/.claude/hooks/`) +### Step 2: Configure global settings -General-purpose scripts that work on any project. +Add these entries to your `~/.claude/settings.json`. These use `git rev-parse` to find the repo — no hardcoded paths needed. -| Script | Event | What it does | -|--------|-------|-------------| -| `tool_use_sound.py` | PreToolUse | Plays keypress sound on tool calls | -| `auto_fix_diagnostics.py` | PostToolUse | Syntax check + seedgo standards checklist after file edits. Fires in subagents too (requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`). | -| `subagent_stop_gate.py` | SubagentStop | Secondary gate — checks modified .py files against seedgo standards before allowing subagent completion. | -| `stop_sound.py` | Stop | Sound on stop | -| `notification_sound.py` | Notification | Sound on notification | +**UserPromptSubmit hooks** (inject prompts every turn): +```json +"UserPromptSubmit": [ + { + "hooks": [{ "type": "command", "command": "REPO=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f \"$REPO/.aipass/aipass_global_prompt.md\" ] && cat \"$REPO/.aipass/aipass_global_prompt.md\" || true" }] + }, + { + "hooks": [{ "type": "command", "command": "REPO=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f \"$REPO/.claude/hooks/branch_prompt_loader.py\" ] && python3 \"$REPO/.claude/hooks/branch_prompt_loader.py\" || true" }] + }, + { + "hooks": [{ "type": "command", "command": "REPO=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f \"$REPO/.claude/hooks/identity_injector.py\" ] && python3 \"$REPO/.claude/hooks/identity_injector.py\" || true" }] + }, + { + "hooks": [{ "type": "command", "command": "REPO=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f \"$REPO/.claude/hooks/email_notification.py\" ] && python3 \"$REPO/.claude/hooks/email_notification.py\" || true" }] + } +] +``` -### Project hooks (`AIPass/.claude/hooks/`) +**PreCompact hooks** (save context before compaction): +```json +"PreCompact": [ + { "matcher": "manual", "hooks": [{ "type": "command", "command": "REPO=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f \"$REPO/.claude/hooks/pre_compact.py\" ] && python3 \"$REPO/.claude/hooks/pre_compact.py\" || true", "timeout": 60 }] }, + { "matcher": "auto", "hooks": [{ "type": "command", "command": "REPO=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f \"$REPO/.claude/hooks/pre_compact.py\" ] && python3 \"$REPO/.claude/hooks/pre_compact.py\" || true", "timeout": 60 }] } +] +``` -AIPass-specific scripts. Referenced from Anthropic settings with absolute paths. +**Optional hooks** (sounds, auto-fix — from global_hooks/): +```json +"PreToolUse": [ + { "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", + "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/tool_use_sound.py" }] } +], +"PostToolUse": [ + { "matcher": "Edit|MultiEdit|Write|NotebookEdit", + "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/auto_fix_diagnostics.py" }] } +], +"Stop": [ + { "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/stop_sound.py" }] } +], +"Notification": [ + { "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/notification_sound.py" }] } +] +``` -| 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 | +### Step 3: Done -The global prompt (`aipass_global_prompt.md`) is injected via a `cat` command in Anthropic settings — no script needed. +Launch Claude from any branch subdirectory: +```bash +cd src/aipass/devpulse +claude --permission-mode bypassPermissions +``` -## What Gets Injected Every Turn +The hooks will auto-discover the repo root and inject the right prompts. -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) +## Why This Architecture -## Directory Structure +Claude Code project settings (`.claude/settings.json`) don't fire `UserPromptSubmit` hooks from subdirectories — only from the repo root. Since AIPass citizens launch from `src/aipass/{name}/`, we can't use project settings for prompt injection. -### Project hooks (`.claude/settings.json`) +The solution: hooks live in **global settings** (`~/.claude/settings.json`) but use `git rev-parse --show-toplevel` to find the repo dynamically. No hardcoded paths. Works for any clone location, any user. Outside a git repo, hooks silently do nothing. -Hooks defined in project settings — fire for project-scoped events. +See DPLAN-0053 for the full investigation and test results. -| Hook | Event | What it does | -|------|-------|-------------| -| `auto_fix_diagnostics.py` | PostToolUse | Same Anthropic hook, also wired at project level for subagent coverage | -| `subagent_stop_gate.py` | SubagentStop | Blocks subagent completion if modified files have seedgo violations | +## What's In This Directory ``` .claude/ -├── settings.json # Permissions, env, project 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 +├── settings.json # Project settings (permissions, env vars, PostToolUse, SubagentStop) +├── hooks/ # AIPass-specific hook scripts (travel with repo) +│ ├── branch_prompt_loader.py # Injects branch-specific prompt based on CWD +│ ├── identity_injector.py # Injects passport identity (role, traits, purpose) +│ ├── email_notification.py # Notifies if unread mail exists +│ ├── pre_compact.py # Saves session context before compaction +│ ├── prompt_inject.sh # Combined inject (reference, not used in production) +│ └── .archive/ # Archived/disabled hooks +├── global_hooks/ # Scripts to copy to ~/.claude/hooks/ (user setup) +│ ├── auto_fix_diagnostics.py # Syntax check + seedgo checklist after edits +│ ├── subagent_stop_gate.py # Blocks subagent if modified files have violations +│ ├── tool_use_sound.py # Keypress sound on tool calls +│ ├── stop_sound.py # Sound on stop +│ ├── notification_sound.py # Sound on notification +│ ├── hook_logger.sh # Optional hook activity logger +│ └── statusline.sh # Statusline display (branch, model, context, cost) +├── agents/ # Agent definitions +│ └── builder.md +├── commands/ # Slash commands +│ └── memo.md # /memo — memory update workflow +├── sounds/ # Audio files for sound hooks +└── README.md # This file ``` +## What Gets Injected Every Turn + +1. **Global Prompt** — system context, terminology, commands, rules (`.aipass/aipass_global_prompt.md`) +2. **Branch Prompt** — branch-specific instructions based on CWD (`.aipass/aipass_local_prompt.md`) +3. **Identity** — passport summary: role, traits, purpose (`.trinity/passport.json`) +4. **Email** — notification only if unread mail exists (`.ai_mail.local/inbox.json`) + +## Project Settings + +Defined in `settings.json` (this directory). These DO fire from subdirectories. + +**Environment:** +- `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` — makes PostToolUse hooks fire inside subagents + +**Permissions:** +- Denied: `git reset`, `git rebase`, `git config`, `git push --force`, `EnterPlanMode` +- Default mode: `acceptEdits` + +**Project hooks:** +- `PostToolUse` — auto-fix diagnostics after file edits (fires in subagents via env var) +- `SubagentStop` — secondary gate checking modified files against seedgo standards + ## Adding a New Hook 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` - -## Subagent Hook Enforcement - -Subagents (spawned via the Agent tool) run in isolation by default — parent hooks don't fire inside them. Two mechanisms enforce standards on subagents: - -1. **`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`** — env var that makes PostToolUse hooks fire inside all built-in subagent types (builder, Explore, Plan, general-purpose). This means `auto_fix_diagnostics.py` runs after every file edit, even inside subagents. - -2. **`SubagentStop` hook** — secondary gate. When a subagent finishes, `subagent_stop_gate.py` checks all modified .py files against seedgo standards. If violations exist, it blocks completion and tells the subagent to fix them. - -No custom agents (`.claude/agents/`) are used — this works with all built-in subagent types. +2. Add one entry to `~/.claude/settings.json` using the `git rev-parse` pattern: + ``` + REPO=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f "$REPO/.claude/hooks/your_script.py" ] && python3 "$REPO/.claude/hooks/your_script.py" || true + ``` +3. Done — no hardcoded paths, works for any clone location diff --git a/.claude/global_hooks/auto_fix_diagnostics.py b/.claude/global_hooks/auto_fix_diagnostics.py new file mode 100644 index 00000000..7e0eb4ab --- /dev/null +++ b/.claude/global_hooks/auto_fix_diagnostics.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +""" +Silent Auto-fix Hook - Runs ACTUAL validation and tells Claude to fix silently. + +Key behaviors: +- Runs real linters (ruff, py_compile) not just pattern lists +- Validates JSON with json.load() +- Outputs via additionalContext so Claude sees errors +- Claude fixes silently without announcing +- Simple indicator for user console +- Smart batching per-file + +Version: 4.3.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"} + +# AIPass-specific Python patterns to check +PYTHON_PATTERNS = { + "bad_optional": { + "pattern": ": str = None", + "message": "Optional param should use 'str | None = None' pattern" + }, + "logger_debug": { + "pattern": "logger.debug(", + "message": "Use logger.info for SystemLogger (logger.debug not supported)" + }, + "return_error_msg": { + "pattern": "return error_msg", + "message": "Return None for error states, not error_msg string" + }, + "open_no_encoding": { + "pattern": "open(", + "requires_missing": "encoding=", + "message": "open() without encoding='utf-8'" + }, + "log_not_log_operation": { + "pattern": ".log(", + "message": "Use log_operation() with success/error params, not .log()" + }, + "dict_none_no_check": { + "pattern": "Dict | None", + "message": "Dict | None return: Add None check before using (if result is None: return)" + } +} + +# JSON-specific patterns for emoji corruption +JSON_CORRUPTION_CHARS = ['\ufffd', '\x00'] + + +def run_python_checks(file_path: str) -> list[str]: + """Run actual Python validation - returns list of errors.""" + errors = [] + + # 1. Syntax check with py_compile + 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 + + # 2. Ruff check (if available) - fast linter + 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]: # Max 5 errors + errors.append(f"LINT: {line}") + except FileNotFoundError: + pass # ruff not installed + except Exception: + pass + + # 3. AIPass-specific pattern checks + try: + content = Path(file_path).read_text(encoding="utf-8") + lines = content.split("\n") + + for check in PYTHON_PATTERNS.values(): + pattern = check["pattern"] + message = check["message"] + requires_missing = check.get("requires_missing") + + # For patterns that require something to be missing + if requires_missing: + if pattern in content and requires_missing not in content: + errors.append(f"PATTERN: {message}") + continue + + # Standard pattern check - scan lines + for line in lines: + stripped = line.strip() + # Skip comments and strings + if stripped.startswith(("#", '"', "'")): + continue + # Skip if pattern appears in a string on this line + if f'"{pattern}' in line or f"'{pattern}" in line: + continue + if pattern in line: + errors.append(f"PATTERN: {message}") + break # One error per pattern type + except Exception: + pass + + return errors + + +def run_json_checks(file_path: str) -> list[str]: + """Run actual JSON validation - returns list of errors.""" + errors = [] + + try: + content = Path(file_path).read_text(encoding="utf-8") + + # Check for emoji corruption before parsing + for char in JSON_CORRUPTION_CHARS: + if char in content: + errors.append(f"EMOJI CORRUPTION: Found corrupted character '{repr(char)}' - check allowed_emojis arrays") + break + + # Try to parse JSON + try: + data = json.loads(content) + + # Check for corruption in emoji arrays specifically + if isinstance(data, dict): + for key in ['allowed_emojis', 'emojis', 'emoji_list']: + if key in data and isinstance(data[key], list): + for item in data[key]: + if isinstance(item, str) and len(item) == 1: + if ord(item) < 128 and item not in '\u2713\u2717': + errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key} - may be corrupted emoji") + break + + 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 run_seedgo_checklist(file_path: str) -> list[str]: + """Run seedgo standards checklist — returns violations only.""" + # Skip Claude hooks - they don't follow project standards + if '/.claude/hooks/' in file_path: + return [] + + try: + result = subprocess.run( + ["drone", "@seedgo", "checklist", file_path], + capture_output=True, + text=True, + timeout=15, + cwd=str(Path.home() / "Projects" / "AIPass") + ) + + if result.returncode != 0: + return [] # Command failed, skip + + violations = [] + current_standard = None + + for line in result.stdout.split("\n"): + line = line.strip() + + # Checklist output format: "✗ standard_name: detail" + if line.startswith("\u2717"): + violation = line[1:].strip() + if violation: + violations.append(violation) + + return violations[:5] # Top 5 only + + except FileNotFoundError: + return [] # drone not available + except Exception: + return [] + + +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", "") + + # Only process edit tools + if tool_name not in EDIT_TOOLS: + return + + # Skip non-code files + if should_skip_file(file_path): + return + + # Smart batching - don't re-check same file + if is_same_file_as_last(file_path): + return + + # Determine file type and run checks + errors = [] + file_type = "" + + if file_path.endswith(".py"): + file_type = "Python" + errors = run_python_checks(file_path) + # Seedgo standards checklist (Python files only) + seedgo_violations = run_seedgo_checklist(file_path) + for v in seedgo_violations: + errors.append(f"SEEDGO: {v}") + + elif file_path.endswith(".json"): + file_type = "JSON" + errors = run_json_checks(file_path) + else: + return # No validation for other types yet + + # Build context message + context = "" + ide_instruction = f"\n\nAlso run mcp__ide__getDiagnostics on {file_path} to check for type errors. Fix any errors found silently." + + if errors: + error_text = "\n".join(f" - {e}" for e in errors) + context = f"""[AUTO-FIX REQUIRED] {len(errors)} validation error(s) in {Path(file_path).name}: +{error_text} + +MANDATORY: You MUST fix every error listed above in {Path(file_path).name} before proceeding with any other work. Edit the file now to resolve these issues. Do not skip, defer, or ignore — fix them immediately, then continue with your task.""" + + # For Python files, also check IDE diagnostics for type errors + if file_path.endswith(".py"): + if errors: + context += ide_instruction + else: + # No other errors, but still check IDE diagnostics + context = f"[TYPE-CHECK] Run mcp__ide__getDiagnostics on {file_path} and fix any type errors silently." + errors = ["IDE diagnostics check requested"] + + output = { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": context + }, + "systemMessage": f"[AUTO-FIX] {len(errors)} issue(s) — fix before continuing" + } + print(json.dumps(output)) + # Exit 0 so JSON additionalContext is delivered to the model + elif errors: + # JSON or other file with errors + output = { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": context + }, + "systemMessage": f"[AUTO-FIX] {len(errors)} issue(s) — fix before continuing" + } + print(json.dumps(output)) + # Exit 0 so JSON additionalContext is delivered to the model + else: + # No errors - just tiny indicator + output = { + "systemMessage": "[diagnostics] ok" + } + print(json.dumps(output)) + + except Exception: + pass # Silent fail + + +if __name__ == "__main__": + main() diff --git a/.claude/global_hooks/hook_logger.sh b/.claude/global_hooks/hook_logger.sh new file mode 100755 index 00000000..aa7598b1 --- /dev/null +++ b/.claude/global_hooks/hook_logger.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Log hook activity for statusline display +# Usage: source from hook commands or call directly +# hook_logger.sh +echo "$(date +%s) $1" > /tmp/aipass-hook-last diff --git a/.claude/global_hooks/notification_sound.py b/.claude/global_hooks/notification_sound.py new file mode 100644 index 00000000..3492e707 --- /dev/null +++ b/.claude/global_hooks/notification_sound.py @@ -0,0 +1,37 @@ +#!/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/global_hooks/statusline.sh b/.claude/global_hooks/statusline.sh new file mode 100755 index 00000000..ebd0530b --- /dev/null +++ b/.claude/global_hooks/statusline.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# AIPass statusline — pretty, context-aware +input=$(cat) + +# Extract fields +dir=$(echo "$input" | jq -r '.workspace.current_dir // ""') +model=$(echo "$input" | jq -r '.model.display_name // "?"') +ctx=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty') +cost=$(echo "$input" | jq -r '.cost.total_cost_usd // empty') +lines_add=$(echo "$input" | jq -r '.cost.total_lines_added // 0') +lines_rm=$(echo "$input" | jq -r '.cost.total_lines_removed // 0') +session=$(echo "$input" | jq -r '.session_name // empty') + +# Colors +RST='\033[0m' +DIM='\033[2m' +BOLD='\033[1m' +GREEN='\033[32m' +YELLOW='\033[33m' +RED='\033[31m' +CYAN='\033[36m' +MAGENTA='\033[35m' +WHITE='\033[97m' + +# Shorten directory — extract branch name if inside AIPass +branch="" +if [[ "$dir" == *"/src/aipass/"* ]]; then + branch=$(echo "$dir" | sed 's|.*/src/aipass/||' | cut -d/ -f1) +elif [[ "$dir" == *"/src/commons"* ]]; then + branch="commons" +elif [[ "$dir" == *"/src/skills"* ]]; then + branch="skills" +elif [[ "$dir" == *"/AIPass"* ]]; then + branch="root" +fi + +# Context color — green > 50%, yellow 20-50%, red < 20% +ctx_color="$GREEN" +if [ -n "$ctx" ]; then + if [ "$ctx" -lt 20 ] 2>/dev/null; then + ctx_color="$RED" + elif [ "$ctx" -lt 50 ] 2>/dev/null; then + ctx_color="$YELLOW" + fi +fi + +# Build context bar (10 chars wide) +bar="" +if [ -n "$ctx" ]; then + used=$((100 - ctx)) + filled=$((used / 10)) + empty=$((10 - filled)) + bar="${DIM}[" + for ((i=0; i/dev/null) + +# Build output +out="" + +# Branch or directory +if [ -n "$branch" ]; then + out+="${CYAN}@${branch}${RST}" +else + short_dir=$(echo "$dir" | sed "s|$HOME|~|" | awk -F/ '{if(NF>=2) print $(NF-1)"/"$NF; else print $NF}') + out+="${DIM}${short_dir}${RST}" +fi + +# Git branch +if [ -n "$git_branch" ]; then + out+=" ${DIM}(${RST}${YELLOW}${git_branch}${RST}${DIM})${RST}" +fi + +# Separator +out+=" ${DIM}│${RST} " + +# Model +out+="${MAGENTA}${short_model}${RST}" + +# Separator +out+=" ${DIM}│${RST} " + +# Context +if [ -n "$ctx" ]; then + out+="${ctx_color}${ctx}%${RST} ${bar}" +else + out+="${DIM}...${RST}" +fi + +# Cost (if any) +if [ -n "$cost" ] && [ "$cost" != "0" ]; then + cost_fmt=$(printf "%.2f" "$cost" 2>/dev/null || echo "$cost") + out+=" ${DIM}│${RST} ${DIM}\$${cost_fmt}${RST}" +fi + +# Lines changed +if [ "$lines_add" -gt 0 ] 2>/dev/null || [ "$lines_rm" -gt 0 ] 2>/dev/null; then + out+=" ${DIM}│${RST} ${GREEN}+${lines_add}${RST}${DIM}/${RST}${RED}-${lines_rm}${RST}" +fi + +# Session name if set +if [ -n "$session" ]; then + out=" ${DIM}[${RST}${WHITE}${session}${RST}${DIM}]${RST} ${out}" +fi + +# Hook activity — show last hook if fired within 3 seconds +HOOK_FILE="/tmp/aipass-hook-last" +if [ -f "$HOOK_FILE" ]; then + hook_data=$(cat "$HOOK_FILE" 2>/dev/null) + hook_ts=$(echo "$hook_data" | cut -d' ' -f1) + hook_name=$(echo "$hook_data" | cut -d' ' -f2-) + now=$(date +%s) + if [ -n "$hook_ts" ] && [ $((now - hook_ts)) -le 3 ] 2>/dev/null; then + out+=" ${DIM}│${RST} ${DIM}hook:${RST}${YELLOW}${hook_name}${RST}" + fi +fi + +printf '%b' "$out" diff --git a/.claude/global_hooks/stop_sound.py b/.claude/global_hooks/stop_sound.py new file mode 100644 index 00000000..59236ff6 --- /dev/null +++ b/.claude/global_hooks/stop_sound.py @@ -0,0 +1,38 @@ +#!/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/global_hooks/subagent_stop_gate.py b/.claude/global_hooks/subagent_stop_gate.py new file mode 100644 index 00000000..2fd640df --- /dev/null +++ b/.claude/global_hooks/subagent_stop_gate.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +SubagentStop Gate — Checks files modified by subagents before allowing them to finish. + +Runs seedgo checklist + basic validation on any .py files the subagent touched. +If violations found, blocks the stop and tells the subagent to fix them. + +Version: 1.0.0 +""" + +import json +import sys +import subprocess +from pathlib import Path + +AIPASS_ROOT = Path.home() / "Projects" / "AIPass" + + +def get_modified_py_files() -> list[str]: + """Get Python files modified in the working tree (unstaged + staged).""" + try: + result = subprocess.run( + ["git", "diff", "--name-only", "HEAD"], + capture_output=True, text=True, timeout=5, + cwd=str(AIPASS_ROOT) + ) + files = [] + for line in result.stdout.strip().split("\n"): + line = line.strip() + if line.endswith(".py") and not line.startswith(".claude/"): + full = AIPASS_ROOT / line + if full.exists(): + files.append(str(full)) + return files + except Exception: + return [] + + +def run_seedgo_checklist(file_path: str) -> list[str]: + """Run seedgo checklist on a single file.""" + if "/.claude/" in file_path: + return [] + try: + result = subprocess.run( + ["drone", "@seedgo", "checklist", file_path], + capture_output=True, text=True, timeout=15, + cwd=str(AIPASS_ROOT) + ) + if result.returncode != 0: + return [] + violations = [] + for line in result.stdout.split("\n"): + line = line.strip() + if line.startswith("\u2717"): + v = line[1:].strip() + if v: + violations.append(v) + return violations[:5] + except Exception: + return [] + + +def main(): + try: + input_data = json.load(sys.stdin) + + modified = get_modified_py_files() + if not modified: + return # Nothing to check + + all_violations = {} + for f in modified: + vs = run_seedgo_checklist(f) + if vs: + name = Path(f).name + all_violations[name] = vs + + if not all_violations: + return # All clear + + # Build the block reason + lines = ["Standards violations found in files you modified:\n"] + for fname, vs in all_violations.items(): + lines.append(f" {fname}:") + for v in vs: + lines.append(f" - {v}") + lines.append("\nFix these violations before finishing.") + + output = { + "decision": "block", + "reason": "\n".join(lines) + } + print(json.dumps(output)) + + except Exception: + pass # Silent fail — don't block on errors + + +if __name__ == "__main__": + main() diff --git a/.claude/global_hooks/tool_use_sound.py b/.claude/global_hooks/tool_use_sound.py new file mode 100644 index 00000000..fe09c5b3 --- /dev/null +++ b/.claude/global_hooks/tool_use_sound.py @@ -0,0 +1,40 @@ +#!/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/hooks/prompt_inject.sh b/.claude/hooks/prompt_inject.sh new file mode 100755 index 00000000..388e6591 --- /dev/null +++ b/.claude/hooks/prompt_inject.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# AIPass Prompt Inject — Called by the global project_bridge.sh +# Runs all AIPass-specific UserPromptSubmit hooks. +# $1 = repo root path (passed by bridge) + +REPO="${1:-$(git rev-parse --show-toplevel 2>/dev/null)}" +[ -z "$REPO" ] && exit 0 + +# 1. Global prompt +cat "$REPO/.aipass/aipass_global_prompt.md" 2>/dev/null + +# 2. Branch prompt loader +python3 "$REPO/.claude/hooks/branch_prompt_loader.py" 2>/dev/null + +# 3. Identity injector +python3 "$REPO/.claude/hooks/identity_injector.py" 2>/dev/null + +# 4. Email notification +python3 "$REPO/.claude/hooks/email_notification.py" 2>/dev/null