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:
+117
-67
@@ -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()
|
||||
Executable
+5
@@ -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()
|
||||
Executable
+125
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+19
@@ -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
|
||||
Reference in New Issue
Block a user