chore(system): portable hooks — git rev-parse, global_hooks supply, setup guide (#58)

Migrate all AIPass hooks from hardcoded paths to git rev-parse --show-toplevel.
Zero AIPass paths remain in global settings. Hooks auto-discover repo root.

- .claude/README.md: complete setup guide (3 steps, exact JSON to paste)
- .claude/global_hooks/: user-level scripts to copy to ~/.claude/hooks/
- .claude/hooks/prompt_inject.sh: combined inject (reference)
- .aipass/aipass_global_prompt.md: STATUS.local.md elevated to equal priority

Related: DPLAN-0053 (Hook Migration — Project-Level Settings)

Co-authored-by: @devpulse <devpulse@aipass>
This commit is contained in:
AIPass
2026-03-16 12:16:16 -07:00
committed by GitHub
co-authored by @devpulse
parent d63b6def07
commit 79b09e8c1d
9 changed files with 791 additions and 67 deletions
+117 -67
View File
@@ -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
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Log hook activity for statusline display
# Usage: source from hook commands or call directly
# hook_logger.sh <hook_name>
echo "$(date +%s) $1" > /tmp/aipass-hook-last
@@ -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()
+125
View File
@@ -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<filled; i++)); do bar+="█"; done
for ((i=0; i<empty; i++)); do bar+="░"; done
bar+="]${RST}"
fi
# Shorten model name
short_model=$(echo "$model" | sed 's/Claude //')
# Git branch
git_branch=$(git -C "$dir" branch --show-current 2>/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"
+38
View File
@@ -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()
+100
View File
@@ -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()
+40
View File
@@ -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()
+19
View File
@@ -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