diff --git a/.aipass/aipass_global_prompt.md b/.aipass/aipass_global_prompt.md index 16ecd8cc..446ac300 100644 --- a/.aipass/aipass_global_prompt.md +++ b/.aipass/aipass_global_prompt.md @@ -79,7 +79,7 @@ Read-only awareness (all branches): All write operations (commit, push, merge, checkout) restricted to devpulse via tier-based access. Dispatched agents build code, run tests — devpulse reviews diff, commits. -Drone runs git via Python subprocess, bypasses settings.json deny rules by design — drone is the gate. `git_gate.py` PreToolUse hook enforces mechanically — applies to ALL sessions including dispatched agents. bypassPermissions does not skip hooks. +Drone runs git via Python subprocess, bypasses settings.json deny rules by design — drone is the gate. Git gate (PreToolUse hook) enforces mechanically — applies to ALL sessions including dispatched agents. bypassPermissions does not skip hooks. Local files = source of truth. Edit file → state on disk IS reality. diff --git a/.claude/README.md b/.claude/README.md index f272b436..5e7f6768 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -1,163 +1,141 @@ -# .claude/ — Claude Code Configuration +# .claude/ -- Claude Code Configuration This directory configures Claude Code for the AIPass project. -**Related:** DPLAN-0053 (Hook Migration) documents the research and decisions behind this architecture. +**Related:** DPLAN-0184 (Hook Migration), DPLAN-0053 (original hook architecture research). + +## How Hooks Work (Post-Migration) + +All AIPass hooks run through a three-layer pipeline: + +``` +~/.claude/settings.json Provider settings (Claude Code reads these) + | + v +claude.py (bridge) Thin entry point -- normalizes stdin, calls engine + | + v +engine.py (dispatcher) Reads .aipass/hooks.json, imports + calls handlers + | + v +handlers/ Native Python handlers (the actual hook logic) +``` + +Provider settings in `~/.claude/settings.json` call the bridge with an event type: + +```json +{ + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse" +} +``` + +The bridge supports two invocation forms: +- `claude.py EventType` -- dispatch ALL enabled hooks for that event +- `claude.py EventType:hook_name` -- dispatch ONLY one specific hook (used for UserPromptSubmit where each hook needs its own system-reminder block) + +Per-project configuration lives in `.aipass/hooks.json`. Each hook entry specifies: +- `enabled` -- whether the hook fires +- `handler` -- dotted import path to the handler function +- `matcher` -- tool name filter (empty string = match all) +- `timeout` -- optional timeout in seconds ## Quick Setup -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. - -### Step 1: Copy global hooks +Run `setup.sh` from the repo root. It creates the venv, installs the package, and wires bridge entries into `~/.claude/settings.json` automatically. ```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/ - -# Optional: copy sounds (if you want audio feedback) -mkdir -p ~/.claude/sounds -cp .claude/sounds/* ~/.claude/sounds/ 2>/dev/null || true +./setup.sh ``` -### Step 2: Configure global settings +If hooks get out of sync, `aipass doctor --fix` can auto-wire missing hook entries. -Add these entries to your `~/.claude/settings.json`. These use `git rev-parse` to find the repo — no hardcoded paths needed. - -**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" }] - }, - { - "hooks": [{ "type": "command", "command": "echo \"# Current Time: $(date +'%A, %B %-d %Y — %-I:%M %p')\"" }] - } -] -``` - -**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 }] } -] -``` - -**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" }] } -] -``` - -### Step 3: Done - -Launch Claude from any branch subdirectory: -```bash -cd src/aipass/devpulse -claude --permission-mode bypassPermissions -``` - -The hooks will auto-discover the repo root and inject the right prompts. - -## Why This Architecture - -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. - -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. - -See DPLAN-0053 for the full investigation and test results. +No manual script copying is needed. No global_hooks directory. No `git rev-parse` tricks. ## What's In This Directory ``` .claude/ -├── 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) +├── settings.json # Project settings (permissions, env vars) +├── hooks/ # Legacy hook scripts (all disabled) + testing tools +│ ├── *.py(disabled) # 18 disabled scripts (pre-migration) +│ ├── hook_log.py # Shared logger -- hooks call run_and_log() +│ ├── hook_report.py # Report tool -- reads JSONL log, shows table +│ ├── hook_test.py # Test harness -- direct + integration tests +│ └── probes/ # Opt-in per-event diagnostic probes ├── agents/ # Agent definitions │ └── builder.md ├── commands/ # Slash commands -│ └── memo.md # /memo — memory update workflow +│ └── memo.md # /memo -- memory update workflow ├── sounds/ # Audio files for sound hooks └── README.md # This file ``` +Hook logic has moved to `src/aipass/hooks/apps/handlers/`. See the handler README for the full layout. + +## Handler Layout + +All 14 hooks are native Python handlers organized by domain: + +``` +src/aipass/hooks/apps/handlers/ +├── bridges/ +│ └── claude.py # Provider bridge (called from settings.json) +├── config/ +│ ├── loader.py # Finds and reads .aipass/hooks.json +│ └── diagnostics.py # JSONL logging for hook execution +├── prompt/ +│ ├── global_loader.py # UserPromptSubmit -- AIPass global prompt +│ ├── branch_loader.py # UserPromptSubmit -- branch-specific prompt +│ └── identity.py # UserPromptSubmit -- passport identity injection +├── notification/ +│ ├── email.py # UserPromptSubmit -- unread email count +│ ├── tool_sound.py # PreToolUse -- key-press sound +│ ├── stop_sound.py # Stop -- achievement bell +│ └── announce.py # Notification -- notification sound +├── security/ +│ ├── git_gate.py # PreToolUse -- blocks raw git/gh commands +│ ├── edit_gate.py # PreToolUse -- cross-branch write block +│ └── subagent_gate.py # SubagentStop -- seedgo checklist gate +└── lifecycle/ + ├── auto_fix.py # PostToolUse -- pyright + ruff after edits + ├── auto_watchdog.py # PostToolUse -- watchdog reminder after dispatch + ├── compact.py # PreCompact -- save context before compaction + └── rollover.py # PreCompact -- memory rollover on compaction +``` + ## 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`) -5. **Time Clock** — current date and time for temporal awareness (added S72, inline shell command) +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`) + +Each is dispatched as a separate `UserPromptSubmit:hook_name` call so it gets its own system-reminder block. ## Project Settings -Defined in `settings.json` (this directory). These DO fire from subdirectories. +Defined in `settings.json` (this directory). These fire from subdirectories. **Environment:** -- `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` — makes PostToolUse hooks fire inside subagents +- `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` -- makes PostToolUse hooks fire inside subagents +- `AIPASS_HOME` -- repo root path, used by bridge commands **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 - -## Time Clock Hook (S72) - -**What:** Injects `# Current Time: Thursday, April 2 2026 — 11:24 AM` as its own system-reminder every turn. - -**Why:** Claude has no temporal awareness by default — doesn't know what time it is, how long a session has been running, or whether it's day/night. The user requested this in S71 as the first step toward autonomous scheduling, task duration estimation, and personal reminders. A year-old wishlist item finally built. - -**How:** Pure inline shell — no script file. Added as a separate entry in `~/.claude/settings.json` UserPromptSubmit array so it gets its own system-reminder block (not buried in the 13.6KB global prompt output). - -**Important:** This hook lives ONLY in `~/.claude/settings.json` (global). It's not a repo script — it's a one-liner `echo` with `date`. First attempt put it inside `prompt_inject.sh` but it got truncated by the 2KB preview limit since the global prompt is 13.6KB. Moving it to its own hook entry fixed visibility. - -**Future:** This is proof-of-concept for a broader temporal awareness system — session duration tracking, task time estimation, reminders (bedtime, meals), autonomous work scheduling. - ## Adding a New Hook -1. Create the script in `.claude/hooks/` -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 +1. Create a handler in `src/aipass/hooks/apps/handlers//your_hook.py` with a `handle(event_type, stdin_data, config)` function +2. Add an entry to `.aipass/hooks.json` under the appropriate event type +3. If the hook needs its own system-reminder output (like prompt injectors), add a separate bridge entry in `~/.claude/settings.json` using the `EventType:hook_name` form +4. Run `setup.sh` or `aipass doctor --fix` to sync provider settings + +## Architecture Notes + +**Why provider settings?** Claude Code project settings (`.claude/settings.json`) do not fire `UserPromptSubmit` hooks from subdirectories. Since AIPass citizens launch from `src/aipass/{name}/`, prompt injection must live in provider settings (`~/.claude/settings.json`). The bridge pattern makes this clean -- one bridge binary, many handlers. + +**Why separate bridge calls for UserPromptSubmit?** Each UserPromptSubmit hook entry gets its own system-reminder block in the conversation. Bundling them into one call would merge all prompt output into a single block, losing separation. + +**Why .aipass/hooks.json?** Decouples hook configuration from provider settings. The engine reads this at dispatch time, so hooks can be enabled/disabled without editing `~/.claude/settings.json`. diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md index 94e0cfe3..1b84e786 100644 --- a/.claude/hooks/README.md +++ b/.claude/hooks/README.md @@ -1,178 +1,96 @@ -# AIPass Hook System +# .claude/hooks/ -- Legacy Hook Scripts (Post-Migration) -Provider-level hooks for the AIPass ecosystem. These fire for every Claude Code -session on this machine via `~/.claude/settings.json`. +> **Migration complete (DPLAN-0184).** All 18 hook scripts in this directory have been +> disabled (renamed with `(disabled)` suffix). Hook logic now lives in native Python +> handlers at `src/aipass/hooks/apps/handlers/`. Provider settings route through the +> bridge at `src/aipass/hooks/apps/handlers/bridges/claude.py`. -## File Layout +## What Remains Active -``` -.claude/hooks/ -├── README.md # This file -│ -│ ── Hooks (wired in ~/.claude/settings.json) ── -├── global_prompt_loader.py # UserPromptSubmit — AIPass global prompt (~22KB) -├── branch_prompt_loader.py # UserPromptSubmit — branch-specific prompt -├── identity_injector.py # UserPromptSubmit — branch identity from passport -├── email_notification.py # UserPromptSubmit — unread email count -├── tool_use_sound.py # PreToolUse — key-press sound on tool calls -├── git_gate.py # PreToolUse — blocks raw git/gh, protects settings -├── auto_fix_diagnostics.py # PostToolUse — pyright + ruff on edited files -├── subagent_stop_gate.py # SubagentStop — seedgo checklist on modified files -├── pre_compact.py # PreCompact — post-compact recovery context -├── stop_sound.py # Stop — achievement bell -├── notification_sound.py # Notification — notification sound -│ -│ ── Also wired but lives in ~/.claude/hooks/ ── -│ pre_edit_gate.py # PreToolUse — cross-branch write block, error-fix gate -│ auto_watchdog.py # PostToolUse — watchdog reminder after dispatch -│ -│ ── Testing & debugging tools ── -├── hook_log.py # Shared logger — every hook calls run_and_log() -├── hook_report.py # Report tool — reads JSONL log, shows table -├── hook_test.py # Test harness — 20 tests (11 direct + 9 integration) -│ -│ ── Legacy probes ── -└── probes/ - ├── README.md - └── probe_*.py # Opt-in per-event diagnostic hooks -``` +Three testing/tooling files are still active in this directory: -## Architecture +| File | Purpose | +|------|---------| +| `hook_log.py` | Shared JSONL logger -- hooks call `run_and_log()` to record execution | +| `hook_report.py` | Report tool -- reads `/tmp/aipass_hook_log.jsonl`, shows table | +| `hook_test.py` | Test harness -- direct + integration tests for hook behavior | -Hooks fire from three levels (can fire simultaneously): +### hook_report.py usage -| Level | Settings file | When it fires | -|-------|--------------|---------------| -| **Provider** | `~/.claude/settings.json` | Every session, everywhere | -| **Project** | `/.claude/settings.json` | When CWD is inside the project | -| **Branch** | deeper `.claude/settings.json` | When CWD is inside that branch | - -**Critical limitation:** PreToolUse and PostToolUse ONLY fire from provider settings. -UserPromptSubmit fires from ALL levels. This means project-level PreToolUse/PostToolUse -hooks provisioned by `aipass init` are dead weight — they never execute. - -## CWD Guards - -Four UserPromptSubmit hooks have CWD-aware guards. When CWD is inside a project that -has its own UserPromptSubmit hooks, the provider hook exits silently — preventing -AIPass context from bleeding into standalone projects. - -Guarded: `global_prompt_loader.py`, `branch_prompt_loader.py`, -`identity_injector.py`, `email_notification.py`. - -## Hook Inventory - -### UserPromptSubmit (provider, CWD-guarded) -| Script | Purpose | -|--------|---------| -| `global_prompt_loader.py` | Injects AIPass global prompt (~22KB) | -| `branch_prompt_loader.py` | Injects branch-specific prompt from `.aipass/aipass_local_prompt.md` | -| `identity_injector.py` | Injects branch identity from `.trinity/passport.json` | -| `email_notification.py` | Shows unread email count from `.ai_mail.local/inbox.json` | - -### PreToolUse (provider only) -| Script | Matcher | Purpose | -|--------|---------|---------| -| `tool_use_sound.py` | Bash\|Edit\|Write\|Read\|... | Plays key-press sound | -| `pre_edit_gate.py` | Edit\|Write\|NotebookEdit | Cross-branch write block + error-fix gate | -| `git_gate.py` | Bash\|Edit\|Write\|NotebookEdit | Blocks raw git/gh, protects settings files | - -### PostToolUse (provider only) -| Script | Matcher | Purpose | -|--------|---------|---------| -| `auto_fix_diagnostics.py` | Edit\|Write\|NotebookEdit | Runs pyright + ruff on edited files | -| `auto_watchdog.py` | Bash | Reminds agent to arm watchdog after dispatch | - -### Other events (provider) -| Script | Event | Purpose | -|--------|-------|---------| -| `subagent_stop_gate.py` | SubagentStop | Runs seedgo checklist on subagent-modified files + hook README reminder | -| `pre_compact.py` | PreCompact | Injects post-compact recovery context | -| `stop_sound.py` | Stop | Plays achievement bell | -| `notification_sound.py` | Notification | Plays notification sound | - -## Testing - -### Execution log (always-on) -Every instrumented hook writes one JSONL line to `/tmp/aipass_hook_log.jsonl` via -`hook_log.py`. Each entry: timestamp, event, source, script, CWD, session, timing, -output_bytes, exit_code. - -### Report tool ```bash python3 .claude/hooks/hook_report.py # Last 5 minutes python3 .claude/hooks/hook_report.py --all # All entries python3 .claude/hooks/hook_report.py --cwd /tmp # Filter by CWD -python3 .claude/hooks/hook_report.py --json # Machine-readable -python3 .claude/hooks/hook_report.py --clear # Wipe log +python3 .claude/hooks/hook_report.py --json # Machine-readable +python3 .claude/hooks/hook_report.py --clear # Wipe log ``` -### Test harness (20 tests) +### hook_test.py usage + ```bash -python3 .claude/hooks/hook_test.py # All 20 tests -python3 .claude/hooks/hook_test.py --direct # 11 direct tests only (fast, ~3s) -python3 .claude/hooks/hook_test.py --integration # 9 integration tests only (~2min) -python3 .claude/hooks/hook_test.py --verbose # Show detail per test +python3 .claude/hooks/hook_test.py # All tests +python3 .claude/hooks/hook_test.py --direct # Direct tests only (fast, ~3s) +python3 .claude/hooks/hook_test.py --integration # Integration tests only (~2min) +python3 .claude/hooks/hook_test.py --verbose # Show detail per test python3 .claude/hooks/hook_test.py --list # List available tests python3 .claude/hooks/hook_test.py --test # Run one test ``` -**Direct tests** (11) pipe JSON to hook scripts via subprocess. Deterministic, -no model, HIGH confidence. Tests CWD guards, git_gate block/allow, settings schema, -project-level guards. +## Disabled Scripts (18 files) -**Integration tests** (9) run `claude -p` from different CWDs and read the JSONL log. -Tests full pipeline including cross-project behavior, subagent hooks, and the -`disableAllHooks` toggle. +These are the original standalone hook scripts. They were disabled as part of DPLAN-0184 +Phase 2 when their logic was migrated to native handlers. The files are kept for reference +but are not executed. -### Disable all hooks -Add `"disableAllHooks": true` to `~/.claude/settings.json`. Remove to re-enable. +| Disabled script | Migrated to | +|-----------------|-------------| +| `global_prompt_loader.py(disabled)` | `handlers/prompt/global_loader.py` | +| `branch_prompt_loader.py(disabled)` | `handlers/prompt/branch_loader.py` | +| `identity_injector.py(disabled)` | `handlers/prompt/identity.py` | +| `email_notification.py(disabled)` | `handlers/notification/email.py` | +| `tool_use_sound.py(disabled)` | `handlers/notification/tool_sound.py` | +| `git_gate.py(disabled)` | `handlers/security/git_gate.py` | +| `pre_edit_gate.py(disabled)` | `handlers/security/edit_gate.py` | +| `auto_fix_diagnostics.py(disabled)` | `handlers/lifecycle/auto_fix.py` | +| `auto_watchdog.py(disabled)` | `handlers/lifecycle/auto_watchdog.py` | +| `subagent_stop_gate.py(disabled)` | `handlers/security/subagent_gate.py` | +| `pre_compact.py(disabled)` | `handlers/lifecycle/compact.py` | +| `pre_compact_rollover.py(disabled)` | `handlers/lifecycle/rollover.py` | +| `stop_sound.py(disabled)` | `handlers/notification/stop_sound.py` | +| `notification_sound.py(disabled)` | `handlers/notification/announce.py` | +| `prompt_inject.sh(disabled)` | (combined inject -- never used in production) | +| `engine.py(disabled)` | `hooks/apps/modules/engine.py` | +| `engine_test_hook.py(disabled)` | (test fixture, no longer needed) | +| `engine_test_sound.py(disabled)` | (test fixture, disabled in hooks.json) | -### Debug mode -```bash -claude --debug hooks --debug-file /tmp/debug.log +All handler paths above are relative to `src/aipass/hooks/apps/`. + +## Probes (Opt-In Diagnostics) + +The `probes/` subdirectory contains passive observer scripts for individual hook events. +These are opt-in, not auto-wired. See `probes/README.md` for usage. + +## New Architecture + +``` +~/.claude/settings.json + | + v +claude.py (bridge) -- thin entry point, normalizes stdin + | + v +engine.py (dispatcher) -- reads .aipass/hooks.json, imports handlers + | + v +handlers/ -- native Python, organized by domain ``` -### Interactive inspection -Type `/hooks` inside a Claude session — shows all hooks with source labels -(`[User]`, `[Project]`, `[Local]`). +For full architecture documentation, see the parent `../.claude/README.md`. -## git_gate.py — Known Limitations +## Related Plans -`git_gate.py` is the **only real enforcement layer** for blocking raw git/gh commands. -`Bash(git *)` deny rules in `settings.json` **do not work** — the permission gate -silently skips content-specific deny patterns. The hook is what actually blocks. - -### What it blocks - -- Bare `git`/`gh` commands (`git status`, `gh pr list`) -- Prefixed variants (`env git status`) -- Drone tier system enforces per-branch write restrictions on top - -### Known bypass vectors (not caught by the hook) - -These are inherent limitations of regex-based command scanning: - -1. **Python subprocess** — `python3 -c 'import subprocess; subprocess.run(["git", "status"])'` - `git` never appears as a bare word in the scanned command -2. **Full binary path** — `/usr/bin/git status` - Lookbehind `(? 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]: - errors.append(f"LINT: {line}") - except FileNotFoundError: - pass - except Exception: - pass - - # 3. Ruff format check — detect format drift - try: - result = subprocess.run(["ruff", "format", "--check", file_path], capture_output=True, text=True, timeout=10) - if result.returncode != 0: - errors.append(f"FORMAT: {Path(file_path).name} needs ruff format (run: ruff format {Path(file_path).name})") - except FileNotFoundError: - pass - except Exception: - pass - - # 4. 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") - - if requires_missing: - if pattern in content and requires_missing not in content: - errors.append(f"PATTERN: {message}") - continue - - for line in lines: - stripped = line.strip() - if stripped.startswith(("#", '"', "'")): - continue - if f'"{pattern}' in line or f"'{pattern}" in line: - continue - if pattern in line: - errors.append(f"PATTERN: {message}") - break - except Exception: - pass - - return errors - - -def run_ruff_lint_structured(file_path: str) -> list[dict]: - """Run ruff check and return structured violations for the state file. - - Returns list of {line, message} dicts — same format as pyright errors. - Only non-empty when ruff finds real violations (not format drift). - """ - if "/.claude/hooks/" in file_path: - return [] - try: - result = subprocess.run( - ["ruff", "check", "--select=E,F,W", "--output-format=json", file_path], - capture_output=True, - text=True, - timeout=10, - ) - if not result.stdout.strip(): - return [] - violations = json.loads(result.stdout) - if not isinstance(violations, list): - return [] - errors = [] - for v in violations[:10]: - line = v.get("location", {}).get("row", 0) - code = v.get("code", "?") - message = v.get("message", "unknown")[:100] - errors.append({"line": line, "message": f"{code}: {message}"}) - return errors - except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired, Exception): - return [] - - -def run_pyright_check(file_path: str) -> list[dict]: - """Run pyright on a single file. Returns list of error dicts.""" - # Skip hook files - they don't follow project standards - if "/.claude/hooks/" in file_path: - return [] - - try: - result = subprocess.run( - [sys.executable, "-m", "pyright", "--outputjson", file_path], capture_output=True, text=True, timeout=15 - ) - - try: - data = json.loads(result.stdout) - except (json.JSONDecodeError, ValueError): - return [] - - errors = [] - for diag in data.get("generalDiagnostics", []): - severity = diag.get("severity", "") - if severity == "error": - line = diag.get("range", {}).get("start", {}).get("line", 0) - message = diag.get("message", "Unknown error") - errors.append({"line": line, "message": message[:100]}) - - return errors[:10] # Max 10 errors - - except FileNotFoundError: - return [] # pyright not installed - except subprocess.TimeoutExpired: - return [] # Timeout — don't block - except Exception: - return [] - - -def save_diagnostics_state(file_path: str, errors: list[dict]): - """Save type errors to state file for PreToolUse gate.""" - try: - if errors: - state = {"file": str(Path(file_path).resolve()), "errors": errors} - STATE_FILE.write_text(json.dumps(state), encoding="utf-8") - else: - # No errors — clear the state - if STATE_FILE.exists(): - STATE_FILE.unlink() - except Exception: - pass - - -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") - - for char in JSON_CORRUPTION_CHARS: - if char in content: - errors.append(f"EMOJI CORRUPTION: Found corrupted character '{repr(char)}'") - break - - try: - data = json.loads(content) - - 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}") - 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.""" - 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 [] - - violations = [] - for line in result.stdout.split("\n"): - line = line.strip() - if line.startswith("\u2717"): - violation = line[1:].strip() - if violation: - violations.append(violation) - - return violations[:5] - - except FileNotFoundError: - return [] - 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 DISABLED — always recheck. - - Previously skipped rechecks on the same file, but this caused - errors introduced on second edit to be missed (state file didn't - exist from first clean edit, so skip triggered). The 1.7s pyright - cost per edit is acceptable for correctness. - """ - return False - - -def _project_has_own_posttooluse_hooks() -> bool: - """Check if CWD is inside a project with its own PostToolUse hooks.""" - search = Path.cwd() - home = Path.home() - while search != home and search.parent != search: - settings = search / ".claude" / "settings.json" - if settings.exists(): - try: - data = json.loads(settings.read_text(encoding="utf-8")) - ptu = data.get("hooks", {}).get("PostToolUse", []) - if ptu: - return True - except (json.JSONDecodeError, OSError): - pass - search = search.parent - return False - - -def main(): - """Main hook entry point.""" - try: - if _project_has_own_posttooluse_hooks(): - return - - input_data = json.load(sys.stdin) - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - file_path = tool_input.get("file_path", "") - - if tool_name not in EDIT_TOOLS: - return - - if should_skip_file(file_path): - return - - if is_same_file_as_last(file_path): - return - - # Collect all errors - errors = [] - - if file_path.endswith(".py"): - errors = run_python_checks(file_path) - - # Seedgo standards checklist - seedgo_violations = run_seedgo_checklist(file_path) - for v in seedgo_violations: - errors.append(f"SEEDGO: {v}") - - # Pyright type errors (single file) - type_errors = run_pyright_check(file_path) - for te in type_errors: - errors.append(f"TYPE: L{te['line']}: {te['message']}") - - # Save ruff lint + type errors to state file for PreToolUse gate (hard block) - ruff_lint_errors = run_ruff_lint_structured(file_path) - save_diagnostics_state(file_path, ruff_lint_errors + type_errors) - - elif file_path.endswith(".json"): - errors = run_json_checks(file_path) - else: - return - - # Build output - if errors: - error_text = "\n".join(f" - {e}" for e in errors) - context = f"""[AUTO-FIX] {len(errors)} error(s) in {Path(file_path).name}: -{error_text} - -Fix these errors in {Path(file_path).name} now. Do not skip or defer.""" - - output = { - "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": context}, - "systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing", - } - print(json.dumps(output)) - else: - output = {"systemMessage": "[diagnostics] ok"} - print(json.dumps(output)) - - except Exception: - pass # Silent fail - - -if __name__ == "__main__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("PostToolUse", "provider", __file__, main) diff --git a/.claude/hooks/auto_watchdog.py b/.claude/hooks/auto_watchdog.py deleted file mode 100644 index cde319ec..00000000 --- a/.claude/hooks/auto_watchdog.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -"""PostToolUse hook — reminds agent to arm watchdog after dispatch. - -Fires after Bash commands containing 'drone @ai_mail dispatch'. -Outputs additionalContext telling the agent to arm the watchdog. -Skips if watchdog is already part of the same command. - -Version: 1.0.0 -""" - -import json -import sys - - -def main(): - """Check if dispatch was run and remind to arm watchdog.""" - try: - hook_input = json.load(sys.stdin) - except (json.JSONDecodeError, EOFError): - return - - tool_name = hook_input.get("tool_name", "") - tool_input = hook_input.get("tool_input", {}) - - if tool_name != "Bash": - return - - command = tool_input.get("command", "") - - # Only trigger on dispatch commands - if "drone @ai_mail dispatch" not in command: - return - - # Skip if watchdog is already in the same command - if "unread_count" in command and "while [" in command: - return - - # Skip if it's just checking dispatch status (not sending) - if "dispatch wake" in command and "dispatch @" not in command: - return - - result = { - "additionalContext": ( - "[AUTO-WATCHDOG] Dispatch detected — arm watchdog NOW. " - "Run the watchdog one-liner from your local prompt with " - "run_in_background: true and timeout: 600000." - ) - } - json.dump(result, sys.stdout) - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/branch_prompt_loader.py b/.claude/hooks/branch_prompt_loader.py deleted file mode 100644 index 56f5ff69..00000000 --- a/.claude/hooks/branch_prompt_loader.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -""" -Branch Prompt Loader — AIPass Public Repo - -Injects branch-specific prompts based on CWD. When working in a branch -directory, loads .aipass/aipass_local_prompt.md and outputs it so the -AI sees branch-specific context. - -When CWD is inside a project that has its own UserPromptSubmit hooks -(e.g. a standalone aipass-init project), this provider-level hook exits -silently to avoid double-firing. - -Version: 1.1.0 -""" - -import json -from pathlib import Path - - -def _project_has_own_hooks() -> bool: - """Check if CWD is inside a project with its own UserPromptSubmit hooks.""" - search = Path.cwd() - home = Path.home() - while search != home and search.parent != search: - settings = search / ".claude" / "settings.json" - if settings.exists(): - try: - data = json.loads(settings.read_text(encoding="utf-8")) - ups = data.get("hooks", {}).get("UserPromptSubmit", []) - if ups: - return True - except (json.JSONDecodeError, OSError): - pass - search = search.parent - return False - - -def find_branch_root() -> Path | None: - """ - Find the branch root directory. - Looks for .trinity/ or .aipass/ as branch indicators. - Stops at the repo root (has pyproject.toml or .git). - """ - cwd = Path.cwd() - search_path = cwd - - while search_path.parent != search_path: - # Branch indicators: has .trinity/ (memory files) or apps/ (code) - has_trinity = (search_path / ".trinity").is_dir() - has_apps = (search_path / "apps").is_dir() - - if has_trinity or has_apps: - return search_path - - # Stop at repo root - if (search_path / "pyproject.toml").exists() or (search_path / ".git").is_dir(): - return None - - search_path = search_path.parent - - return None - - -def main(): - if _project_has_own_hooks(): - return - - branch_root = find_branch_root() - - if branch_root: - prompt_file = branch_root / ".aipass" / "aipass_local_prompt.md" - if prompt_file.exists(): - content = prompt_file.read_text().strip() - branch_name = branch_root.name.upper() - print(f"\n# Branch Context: {branch_name}\n\n{content}") - - integrations_dir = branch_root / "apps" / "integrations" - if integrations_dir.is_dir(): - for prompt in sorted(integrations_dir.glob("*/private_prompt.md")): - print(f"\n{prompt.read_text().strip()}") - - -if __name__ == "__main__": - import sys - - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("UserPromptSubmit", "provider", __file__, main) diff --git a/.claude/hooks/email_notification.py b/.claude/hooks/email_notification.py deleted file mode 100644 index 70f6df76..00000000 --- a/.claude/hooks/email_notification.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -""" -Email Notification Hook - Notifies of new emails on prompt submit. - -Checks the current branch's inbox for unread emails and displays -a notification if any exist. - -When CWD is inside a project that has its own UserPromptSubmit hooks, -this provider-level hook exits silently to avoid double-firing. - -Version: 1.1.0 -""" - -import json -from pathlib import Path - - -def _project_has_own_hooks() -> bool: - """Check if CWD is inside a project with its own UserPromptSubmit hooks.""" - search = Path.cwd() - home = Path.home() - while search != home and search.parent != search: - settings = search / ".claude" / "settings.json" - if settings.exists(): - try: - data = json.loads(settings.read_text(encoding="utf-8")) - ups = data.get("hooks", {}).get("UserPromptSubmit", []) - if ups: - return True - except (json.JSONDecodeError, OSError): - pass - search = search.parent - return False - - -def find_repo_root() -> Path | None: - """Find the repo root (contains pyproject.toml or .git).""" - search = Path.cwd() - while search.parent != search: - if (search / "pyproject.toml").exists() or (search / ".git").is_dir(): - return search - search = search.parent - return None - - -def find_branch_root() -> Path | None: - """Find the branch root directory by walking up from CWD.""" - cwd = Path.cwd() - repo_root = find_repo_root() - if not repo_root: - return None - - search_path = cwd - for _ in range(10): - has_trinity = (search_path / ".trinity").is_dir() - has_id = list(search_path.glob("*.id.json")) - has_apps = (search_path / "apps").is_dir() - has_mail = (search_path / ".ai_mail.local").is_dir() or (search_path / "ai_mail.local").is_dir() - - if (has_trinity or has_id or has_apps or has_mail) and search_path != repo_root: - return search_path - - if search_path == repo_root: - break - - parent = search_path.parent - if parent == search_path: - break - search_path = parent - - return None - - -def count_new_emails(branch_root: Path) -> int: - """Count new (unread) emails in the branch's inbox.""" - # Check both patterns: .ai_mail.local (canonical) and ai_mail.local (legacy) - inbox_path = branch_root / ".ai_mail.local" / "inbox.json" - if not inbox_path.exists(): - inbox_path = branch_root / "ai_mail.local" / "inbox.json" - - if not inbox_path.exists(): - return 0 - - try: - with open(inbox_path, "r", encoding="utf-8") as f: - data = json.load(f) - - # Handle both formats: {"messages": [...]} and bare [...] - messages = data if isinstance(data, list) else data.get("messages", []) - count = 0 - for msg in messages: - if msg.get("status") == "new": - count += 1 - elif msg.get("status") is None and not msg.get("read", False): - count += 1 - - return count - - except (json.JSONDecodeError, OSError): - return 0 - - -def main(): - if _project_has_own_hooks(): - return - - branch_root = find_branch_root() - if not branch_root: - return - - new_count = count_new_emails(branch_root) - if new_count > 0: - plural = "s" if new_count != 1 else "" - print( - f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view | close with: drone @ai_mail close " - ) - - -if __name__ == "__main__": - import sys - - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("UserPromptSubmit", "provider", __file__, main) diff --git a/.claude/hooks/engine.py b/.claude/hooks/engine.py deleted file mode 100644 index 4c46bcd3..00000000 --- a/.claude/hooks/engine.py +++ /dev/null @@ -1,215 +0,0 @@ -# =================== AIPass ==================== -# Name: engine.py -# Description: Hook Engine — unified dispatcher for all hook events -# Version: 0.3.0 -# Created: 2026-05-17 -# Modified: 2026-05-17 -# ============================================= - -""" -Hook Engine — DPLAN-0184 Phase 1. - -Single entry point for all hook events. Reads per-project config (.aipass/hooks.json), -dispatches to registered hooks, logs everything via prax logger. - -Called from provider settings (must use venv python for prax imports): - $AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/.claude/hooks/engine.py - -Stdin/stdout contract matches the platform's hook interface. -""" - -import json -import os -import subprocess -import sys -import time -from pathlib import Path - -from aipass.prax.apps.modules.logger import system_logger as logger - -AIPASS_HOME = os.environ.get("AIPASS_HOME", "") -LOG_DIR = Path(AIPASS_HOME) / ".claude" / "hooks" if AIPASS_HOME else Path(__file__).parent -LOG_FILE = LOG_DIR / "engine.jsonl" - - -def _find_project_config() -> dict | None: - """Walk up from CWD looking for .aipass/hooks.json.""" - search = Path.cwd() - home = Path.home() - while search != home and search.parent != search: - config = search / ".aipass" / "hooks.json" - if config.exists(): - try: - raw = config.read_text(encoding="utf-8") - if AIPASS_HOME: - raw = raw.replace("$AIPASS_HOME", AIPASS_HOME) - return json.loads(raw) - except (json.JSONDecodeError, OSError) as exc: - logger.error("[hook_engine] bad config %s: %s", config, exc) - return None - search = search.parent - return None - - -def _log(entry: dict) -> None: - """Append a JSONL log entry for detailed diagnostics.""" - try: - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") - except OSError as exc: - logger.error("[hook_engine] log write failed: %s", exc) - - -def _run_hook(hook_cmd: str, stdin_data: str) -> dict: - """Run a single hook subprocess, capture output and timing.""" - env = os.environ.copy() - start = time.monotonic() - try: - result = subprocess.run( - hook_cmd, - shell=True, - input=stdin_data, - capture_output=True, - text=True, - timeout=30, - env=env, - ) - elapsed_ms = (time.monotonic() - start) * 1000 - return { - "exit_code": result.returncode, - "stdout": result.stdout, - "stderr": result.stderr, - "elapsed_ms": round(elapsed_ms, 1), - } - except subprocess.TimeoutExpired: - elapsed_ms = (time.monotonic() - start) * 1000 - logger.error("[hook_engine] timeout after 30s: %s", hook_cmd) - return {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": round(elapsed_ms, 1)} - except OSError as exc: - elapsed_ms = (time.monotonic() - start) * 1000 - logger.error("[hook_engine] exec error: %s: %s", hook_cmd, exc) - return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)} - - -def _matches(matcher: str, value: str) -> bool: - """Check if a hook's matcher string matches the given value.""" - if not matcher: - return True - return value in matcher.split("|") - - -def dispatch(event_type: str, stdin_data: str, config: dict) -> str: - """Core dispatch — run hooks for event, return merged stdout.""" - if not config.get("hooks_enabled", True): - logger.info("[hook_engine] all hooks disabled") - _log({"ts": time.time(), "event": event_type, "action": "all_hooks_disabled"}) - return "" - - event_hooks = config.get(event_type, {}) - if not event_hooks: - _log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"}) - return "" - - match_value = "" - try: - parsed = json.loads(stdin_data) if stdin_data.strip() else {} - match_value = parsed.get("tool_name", "") or parsed.get("compact_type", "") or parsed.get("type", "") - except json.JSONDecodeError as exc: - logger.warning("[hook_engine] stdin parse error: %s", exc) - - outputs = [] - total_start = time.monotonic() - - for hook_name, hook_def in event_hooks.items(): - if not hook_def.get("enabled", True): - logger.info("[hook_engine] %s.%s skipped (disabled)", event_type, hook_name) - _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_disabled"}) - continue - - command = hook_def.get("command", "") - matcher = hook_def.get("matcher", "") - if not command: - continue - - if matcher and not _matches(matcher, match_value): - _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_no_match", - "matcher": matcher, "value": match_value}) - continue - - result = _run_hook(command, stdin_data) - - logger.info( - "[hook_engine] %s.%s exit=%d out=%db %dms", - event_type, hook_name, result["exit_code"], - len(result["stdout"]), result["elapsed_ms"], - ) - _log({ - "ts": time.time(), - "event": event_type, - "hook": hook_name, - "exit_code": result["exit_code"], - "elapsed_ms": result["elapsed_ms"], - "stdout_len": len(result["stdout"]), - "stderr_preview": result["stderr"][:200] if result["stderr"] else "", - "cwd": str(Path.cwd()), - }) - - if result["exit_code"] == 2: - is_intentional_block = False - try: - decision = json.loads(result["stdout"]) if result["stdout"].strip() else {} - is_intentional_block = decision.get("decision") == "block" - except (json.JSONDecodeError, AttributeError): - pass - - if is_intentional_block: - total_ms = (time.monotonic() - total_start) * 1000 - logger.warning( - "[hook_engine] %s BLOCKED by %s (%dms)", - event_type, hook_name, total_ms, - ) - _log({"ts": time.time(), "event": event_type, "action": "blocked", - "hook": hook_name, "total_ms": round(total_ms, 1)}) - return result["stdout"] - - logger.error( - "[hook_engine] %s.%s CRASHED exit=2: %s", - event_type, hook_name, result["stderr"][:200], - ) - _log({"ts": time.time(), "event": event_type, "hook": hook_name, - "action": "crashed", "stderr": result["stderr"][:200]}) - - if result["stdout"]: - outputs.append(result["stdout"]) - - total_ms = (time.monotonic() - total_start) * 1000 - logger.info("[hook_engine] %s complete: %d hooks %dms", event_type, len(outputs), total_ms) - _log({"ts": time.time(), "event": event_type, "action": "complete", - "hooks_run": len(outputs), "total_ms": round(total_ms, 1)}) - - return "\n".join(outputs) - - -def main() -> None: - """Entry point — receive event type, dispatch, output result.""" - if len(sys.argv) < 2: - sys.stderr.write("Usage: engine.py \n") - sys.exit(1) - - event_type = sys.argv[1] - - stdin_data = "" - if not sys.stdin.isatty(): - stdin_data = sys.stdin.read() - - config = _find_project_config() - if config is None: - config = {"hooks_enabled": True} - - output = dispatch(event_type, stdin_data, config) - if output: - sys.stdout.write(output) - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/engine_test_hook.py b/.claude/hooks/engine_test_hook.py deleted file mode 100644 index c766e445..00000000 --- a/.claude/hooks/engine_test_hook.py +++ /dev/null @@ -1,54 +0,0 @@ -# =================== AIPass ==================== -# Name: engine_test_hook.py -# Description: Test hook for engine POC — proves engine dispatch works -# Version: 0.1.0 -# Created: 2026-05-17 -# Modified: 2026-05-17 -# ============================================= - -""" -Test hook that proves the engine dispatched correctly. -Writes a timestamped entry to engine_test.log and outputs a system reminder. -Safe — no side effects beyond logging. -""" - -import json -import os -import sys -import time -from pathlib import Path - -LOG_FILE = Path(os.environ.get("AIPASS_HOME", "")) / ".claude" / "hooks" / "engine_test.log" - - -def main() -> None: - """Log proof of execution and output a system reminder.""" - stdin_data = "" - if not sys.stdin.isatty(): - stdin_data = sys.stdin.read() - - event_info = {} - try: - if stdin_data.strip(): - event_info = json.loads(stdin_data) - except json.JSONDecodeError as exc: - sys.stderr.write(f"engine_test_hook: parse error: {exc}\n") - - entry = { - "ts": time.time(), - "hook": "engine_test_hook", - "cwd": str(Path.cwd()), - "event_keys": list(event_info.keys())[:5], - } - - try: - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(json.dumps(entry) + "\n") - except OSError as exc: - sys.stderr.write(f"engine_test_hook: log failed: {exc}\n") - - sys.stdout.write("ENGINE_TEST: Hook engine dispatched successfully\n") - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/engine_test_sound.py b/.claude/hooks/engine_test_sound.py deleted file mode 100644 index d453b310..00000000 --- a/.claude/hooks/engine_test_sound.py +++ /dev/null @@ -1,40 +0,0 @@ -# =================== AIPass ==================== -# Name: engine_test_sound.py -# Description: Test sound hook for engine POC — plays distinct beep -# Version: 0.2.0 -# Created: 2026-05-17 -# Modified: 2026-05-18 -# ============================================= - -"""Plays a distinct A5 beep to prove the engine dispatched this hook.""" - -import subprocess -import sys -from pathlib import Path - -SOUNDS_DIR = Path(__file__).parent.parent / "sounds" -DEFAULT_SOUND = SOUNDS_DIR / "engine_test_beep.wav" - - -def main() -> None: - """Play the test beep sound. Accepts optional sound file arg.""" - sound = DEFAULT_SOUND - if len(sys.argv) > 1: - candidate = Path(sys.argv[1]) - if candidate.exists(): - sound = candidate - - if not sound.exists(): - return - try: - subprocess.run( - ["aplay", "-q", str(sound)], - timeout=5, check=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - except (OSError, subprocess.TimeoutExpired): - pass - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/git_gate.py b/.claude/hooks/git_gate.py deleted file mode 100755 index 44ed4f5f..00000000 --- a/.claude/hooks/git_gate.py +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -"""PreToolUse Gate — blocks raw git/gh writes + edits to settings/hooks files. - -Dispatched agents spawn with --permission-mode bypassPermissions, which skips -all permissions.deny rules in every settings tier. PreToolUse hooks remain the -only mechanical chokepoint that survives. This hook gates the dangerous -shortcuts and redirects callers to drone. - -Allows: read-only git/gh, all unrelated tool calls, devpulse-from-its-own-branch - edits to the enforcement layer itself. -Blocks: git write verbs, gh state-changing subcommands, edits to .claude - settings.json / hooks/ and .git/hooks/. - -DPLAN-0162. -""" - -import json -import os -import re -import sys -from pathlib import Path - -BLOCKED_GIT_VERBS = ( - "commit", - "push", - "pull", - "merge", - "rebase", - "reset", - "checkout", - "switch", - "cherry-pick", - "revert", - "rm", - "mv", - "restore", - "clean", - "config", -) - -BLOCKED_GIT_RE = re.compile( - r"(? # merge a PR (devpulse only)\n" - " drone @git issue list/create/view # gh issue passthrough\n" - "Read-only gh (list, view, status, diff, checks, comments) is allowed." -) - -EDIT_REDIRECT = ( - "{path} is protected — settings.json, .claude/hooks/, and .git/hooks/ " - "govern the enforcement layer itself.\n" - "If a real change is needed, ask devpulse to make it directly." -) - - -def _block(reason: str) -> None: - print(json.dumps({"decision": "block", "reason": reason})) - sys.exit(2) - - -def _cwd_branch(cwd: str) -> str: - """Extract AIPass branch name from CWD (src/aipass/{branch}/ pattern).""" - parts = Path(cwd).parts - for i, part in enumerate(parts): - if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts): - return parts[i + 1] - return "" - - -def _is_project_owner(cwd: str) -> bool: - """Check if the current branch's passport has citizenship.owner: true.""" - p = Path(cwd) - for d in [p] + list(p.parents): - passport = d / ".trinity" / "passport.json" - if passport.is_file(): - try: - data = json.loads(passport.read_text(encoding="utf-8")) - return bool(data.get("citizenship", {}).get("owner")) - except Exception: - return False - if (d / ".git").exists(): - break - return False - - -def main(): - try: - data = json.load(sys.stdin) - tool_name = data.get("tool_name", "") - tool_input = data.get("tool_input", {}) - cwd = data.get("cwd") or os.getcwd() - - if tool_name == "Bash": - cmd = tool_input.get("command", "") - if not cmd: - return - - # Subprocess bypass detection — scan raw command for git/gh inside - # subprocess/os execution patterns before stripping quotes. - if re.search(r"subprocess\.\w+|os\.system|os\.popen|Popen|(? bool: - """Check if CWD is inside a project with its own UserPromptSubmit hooks.""" - search = Path.cwd() - home = Path.home() - while search != home and search.parent != search: - settings = search / ".claude" / "settings.json" - if settings.exists(): - try: - data = json.loads(settings.read_text(encoding="utf-8")) - ups = data.get("hooks", {}).get("UserPromptSubmit", []) - if ups: - return True - except (json.JSONDecodeError, OSError): - pass - search = search.parent - return False - - -def main(): - if _project_has_own_hooks(): - return - - aipass_home = os.environ.get("AIPASS_HOME", "") - if not aipass_home: - return - - prompt_file = Path(aipass_home) / ".aipass" / "aipass_global_prompt.md" - if prompt_file.exists(): - print(prompt_file.read_text(encoding="utf-8"), end="") - - -if __name__ == "__main__": - import sys - - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("UserPromptSubmit", "provider", __file__, main) diff --git a/.claude/hooks/identity_injector.py b/.claude/hooks/identity_injector.py deleted file mode 100644 index 69f25ff3..00000000 --- a/.claude/hooks/identity_injector.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -""" -Identity Injector - Injects branch identity on every prompt. - -Reads from [BRANCH].id.json and outputs core identity fields. -Finds the branch root by walking up from CWD looking for apps/ or *.id.json. - -When CWD is inside a project that has its own UserPromptSubmit hooks, -this provider-level hook exits silently to avoid double-firing. - -Version: 1.1.0 -""" - -import json -from pathlib import Path - - -def _project_has_own_hooks() -> bool: - """Check if CWD is inside a project with its own UserPromptSubmit hooks.""" - search = Path.cwd() - home = Path.home() - while search != home and search.parent != search: - settings = search / ".claude" / "settings.json" - if settings.exists(): - try: - data = json.loads(settings.read_text(encoding="utf-8")) - ups = data.get("hooks", {}).get("UserPromptSubmit", []) - if ups: - return True - except (json.JSONDecodeError, OSError): - pass - search = search.parent - return False - - -def find_repo_root() -> Path | None: - """Find the repo root (contains pyproject.toml or .git).""" - search = Path.cwd() - while search.parent != search: - if (search / "pyproject.toml").exists() or (search / ".git").is_dir(): - return search - search = search.parent - return None - - -def find_branch_root() -> Path | None: - """Find the branch root directory by walking up from CWD.""" - cwd = Path.cwd() - repo_root = find_repo_root() - if not repo_root: - return None - - search_path = cwd - while search_path >= repo_root: - has_trinity = (search_path / ".trinity").is_dir() - has_id = list(search_path.glob("*.id.json")) - - if has_trinity or has_id: - return search_path - - if search_path == repo_root: - break - search_path = search_path.parent - - return None - - -def find_id_file(branch_root: Path) -> Path | None: - """Find the identity file for a branch (.trinity/passport.json or *.id.json).""" - # AIPass pattern: .trinity/passport.json - passport = branch_root / ".trinity" / "passport.json" - if passport.exists(): - return passport - # Dev-Pass fallback: *.id.json - id_files = list(branch_root.glob("*.id.json")) - if id_files: - return id_files[0] - return None - - -def format_identity(data: dict) -> str: - """Format branch_info + identity for injection.""" - lines = [] - - # Try branch_info first (enriched passports), fall back to identity block (setup.sh passports) - branch = data.get("branch_info", {}) - identity = data.get("identity", {}) - name = branch.get("branch_name") or identity.get("name", "UNKNOWN") - lines.append(f"# {name} Identity") - lines.append(f"Path: {branch.get('path', 'unknown')}") - lines.append(f"Email: {branch.get('email', 'unknown')}") - - identity = data.get("identity", {}) - if identity.get("role"): - lines.append(f"Role: {identity['role']}") - traits = identity.get("traits") or data.get("traits") - if traits: - if isinstance(traits, list): - lines.append("Traits: " + " | ".join(traits)) - else: - lines.append(f"Traits: {traits}") - if identity.get("purpose"): - lines.append(f"Purpose: {identity['purpose']}") - - what_i_do = identity.get("what_i_do", []) - if what_i_do: - lines.append("Do: " + " | ".join(what_i_do[:4])) - - what_i_dont_do = identity.get("what_i_dont_do", []) - if what_i_dont_do: - lines.append("Don't: " + " | ".join(what_i_dont_do[:3])) - - principles = data.get("principles", []) - if principles: - lines.append("Principles: " + " * ".join(principles)) - - return "\n".join(lines) - - -def main(): - if _project_has_own_hooks(): - return - - branch_root = find_branch_root() - if not branch_root: - return - - id_file = find_id_file(branch_root) - if not id_file or not id_file.exists(): - return - - try: - data = json.loads(id_file.read_text(encoding="utf-8")) - output = format_identity(data) - if output: - print(f"\n{output}") - except (json.JSONDecodeError, KeyError): - pass - - -if __name__ == "__main__": - import sys - - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("UserPromptSubmit", "provider", __file__, main) diff --git a/.claude/hooks/notification_sound.py b/.claude/hooks/notification_sound.py deleted file mode 100644 index 50b94f21..00000000 --- a/.claude/hooks/notification_sound.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -# Version: 1.0.0 -"""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__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("Notification", "provider", __file__, main) diff --git a/.claude/hooks/pre_compact.py b/.claude/hooks/pre_compact.py deleted file mode 100644 index 4782cb97..00000000 --- a/.claude/hooks/pre_compact.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env python3 -""" -Pre-Compact Hook - Inject live state for post-compact recovery. - -Reads STATUS.local.md, last session from local.json, and git branch -to give the model real context after compaction — not generic advice. - -Version: 3.0.0 -""" - -import json -import subprocess -import sys -from pathlib import Path - - -def _find_branch_dir(): - """Find the current branch directory from CWD.""" - cwd = Path.cwd() - - # Check if we're in a branch dir or subdirectory of one - # Pattern: .../src/aipass/{branch}/... - parts = cwd.parts - for i, part in enumerate(parts): - if part == "aipass" and i > 0 and parts[i - 1] == "src": - branch_dir = Path(*parts[: i + 2]) - if branch_dir.is_dir(): - return branch_dir - - # Check if CWD itself has .trinity/ - if (cwd / ".trinity").is_dir(): - return cwd - - return None - - -def _read_status_local(branch_dir): - """Read STATUS.local.md if it exists.""" - for name in ["STATUS.local.md", "dev.local.md"]: - path = branch_dir / name - if path.is_file(): - try: - return path.read_text(encoding="utf-8")[:3000] - except Exception: - pass - return None - - -def _read_last_session(branch_dir): - """Read the most recent session and key_learnings from local.json.""" - local_path = branch_dir / ".trinity" / "local.json" - if not local_path.is_file(): - return None - - try: - data = json.loads(local_path.read_text(encoding="utf-8")) - result = [] - - # Last session - sessions = data.get("sessions", []) - if sessions: - last = sessions[0] - result.append( - f"Last session (#{last.get('session_number', '?')}, " - f"{last.get('date', '?')}): {last.get('summary', 'no summary')}" - ) - - # Key learnings (just the keys, not full values — breadcrumbs) - learnings = data.get("key_learnings", {}) - if learnings: - keys = list(learnings.keys())[-10:] # last 10 - result.append(f"Key learnings available: {', '.join(keys)}") - - return "\n".join(result) if result else None - except Exception: - return None - - -def _get_git_info(): - """Get current git branch and short status.""" - try: - branch = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, - text=True, - timeout=5, - ) - status = subprocess.run( - ["git", "diff", "--stat", "--cached", "HEAD"], - capture_output=True, - text=True, - timeout=5, - ) - dirty = subprocess.run( - ["git", "status", "--porcelain"], - capture_output=True, - text=True, - timeout=5, - ) - - result = [] - if branch.returncode == 0: - result.append(f"Git branch: {branch.stdout.strip()}") - if dirty.returncode == 0 and dirty.stdout.strip(): - lines = dirty.stdout.strip().split("\n") - result.append(f"Uncommitted changes: {len(lines)} files") - - return "\n".join(result) if result else None - except Exception: - return None - - -def _get_branch_name(branch_dir): - """Extract branch name from directory.""" - return branch_dir.name if branch_dir else "unknown" - - -def main(): - """Main hook entry point.""" - try: - json.load(sys.stdin) - - branch_dir = _find_branch_dir() - branch_name = _get_branch_name(branch_dir) - - sections = [] - - sections.append(f"""POST-COMPACT RECOVERY — @{branch_name} - -Context just compacted. Below is your live state. Use it to continue seamlessly.""") - - # Git info - git_info = _get_git_info() - if git_info: - sections.append(f"## Git\n{git_info}") - - # Last session from local.json - if branch_dir: - session_info = _read_last_session(branch_dir) - if session_info: - sections.append(f"## Last Session\n{session_info}") - - # STATUS.local.md — the main context - if branch_dir: - status = _read_status_local(branch_dir) - if status: - sections.append(f"## STATUS.local.md\n{status}") - - # Recovery instructions — different for dispatched agents vs interactive - import os - - is_dispatched = os.environ.get("AIPASS_SESSION_TYPE") == "dispatched" - - if is_dispatched: - sections.append("""## DISPATCHED AGENT — SAVE STATE NOW -Before continuing work, you MUST update your memories: -1. Update .trinity/local.json — add/update current session with work done so far -2. Update STATUS.local.md — ensure Current Work reflects what you've accomplished -3. Then continue your task from where the summary left off - -This is non-optional. Compaction just happened — if you don't save now, work history is lost.""") - else: - sections.append("""## Recovery Protocol -- Continue where the summary left off — don't restart or ask generic questions -- .trinity/local.json has full session history and key_learnings — read it if you need more context -- STATUS.local.md has current work, known issues, and todos -- Save memories proactively — compaction just proved you need to -- Match the conversation tone from before compaction""") - - print("\n\n".join(sections), file=sys.stdout) - print("Pre-compact: live state injected", file=sys.stderr) - - except Exception as e: - # Fail silently — never block compaction - print(f"Pre-compact hook error: {e}", file=sys.stderr) - - sys.exit(0) - - -if __name__ == "__main__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("PreCompact", "provider", __file__, main) diff --git a/.claude/hooks/pre_edit_gate.py b/.claude/hooks/pre_edit_gate.py deleted file mode 100644 index c9c85035..00000000 --- a/.claude/hooks/pre_edit_gate.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -""" -PreToolUse Gate — Blocks unsafe edits at the hook layer. - -Rules (checked in order): - 1. Inbox lock — any write targeting *.ai_mail.local/inbox.json is BLOCKED. - Use `drone @ai_mail email` instead. - 2. Cross-branch — writes to src/aipass/X/** from a CWD inside src/aipass/Y/** - are BLOCKED unless the calling branch is in TRUSTED_CROSS_WRITERS. - 3. State-file — edits to OTHER .py files while the current branch has unresolved - type errors are BLOCKED. (original v1.2.0 logic) - -Track E additions: rules 1 + 2 (DPLAN-0139). -Version: 1.3.0 -""" - -import json -import os -import sys -from pathlib import Path - -STATE_FILE = Path(__file__).parent / ".diagnostics_state.json" -EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} - -# Single source of truth lives in permissions.py — inline here as fallback -# so the hook works even when aipass package is not on sys.path. -TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn") - - -def _get_branch(file_path: str) -> str: - """Extract AIPass branch name from a file path (src/aipass/{branch}/ pattern).""" - parts = Path(file_path).parts - for i, part in enumerate(parts): - if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts): - return parts[i + 1] - return "" - - -def _block(reason: str) -> None: - # codeql[py/clear-text-logging-sensitive-data] - print(json.dumps({"decision": "block", "reason": reason})) - sys.exit(2) - - -def main(): - try: - input_data = json.load(sys.stdin) - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - file_path = tool_input.get("file_path", "") - - if tool_name not in EDIT_TOOLS: - return - - if not file_path: - return - - # ------------------------------------------------------------------ - # Rule 1: Inbox lock — block all writes to *.ai_mail.local/inbox.json - # ------------------------------------------------------------------ - fp = Path(file_path) - if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts: - _block('Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @ "Subject" "Body"') - - # ------------------------------------------------------------------ - # Rule 1.5: Dispatched-agent path confinement (DPLAN-0155 M3) - # Daemon-spawned agents can only write inside their own branch dir. - # Breaks the prompt-injection amplifier chain — even if injected, - # a dispatched agent cannot write to other agents' inboxes or code. - # ------------------------------------------------------------------ - cwd = input_data.get("cwd", "") or os.getcwd() - cwd_branch = _get_branch(cwd) - - session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive") - if session_type == "daemon" and cwd_branch: - target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp)) - if target_branch and target_branch != cwd_branch: - _block( - f"Dispatched agent confined to own branch: '{cwd_branch}' " - f"cannot write to '{target_branch}' in daemon mode." - ) - repo_root = None - for parent in Path(cwd).parents: - if (parent / ".git").exists(): - repo_root = parent - break - if repo_root and not target_branch: - allowed_prefix = str(repo_root / "src" / "aipass" / cwd_branch) - resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp) - if not resolved.startswith(allowed_prefix): - _block(f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}") - - # ------------------------------------------------------------------ - # Rule 2: Cross-branch write enforcement - # ------------------------------------------------------------------ - target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp)) - - if cwd_branch and target_branch and cwd_branch != target_branch: - if cwd_branch not in TRUSTED_CROSS_WRITERS: - _block( - f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n" - f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}" - ) - - # ------------------------------------------------------------------ - # Rule 3: State-file (original v1.2.0) — .py files only - # ------------------------------------------------------------------ - if not file_path.endswith(".py"): - return - - if not STATE_FILE.exists(): - return - - try: - state = json.loads(STATE_FILE.read_text(encoding="utf-8")) - except (json.JSONDecodeError, IOError): - return - - errored_file = state.get("file", "") - errors = state.get("errors", []) - - if not errors: - return - - try: - current = str(Path(file_path).resolve()) - errored = str(Path(errored_file).resolve()) - except (OSError, ValueError): - return - - if current == errored: - return - - current_branch = _get_branch(current) - errored_branch = _get_branch(errored) - if not errored_branch: - return - if current_branch and errored_branch and current_branch != errored_branch: - return - - error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5]) - _block(f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}") - - except Exception: - pass # Silent fail → allow - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/probes/README.md b/.claude/hooks/probes/README.md index a030ca3c..d93f8641 100644 --- a/.claude/hooks/probes/README.md +++ b/.claude/hooks/probes/README.md @@ -2,11 +2,16 @@ > **Note:** The probe suite predates the `hook_log.py` always-on logger (S132, DPLAN-0167). > For most hook debugging, use `hook_report.py` and `hook_test.py` in the parent directory -> instead — they cover all hooks automatically without manual wiring. The probes below remain +> instead -- they cover all hooks automatically without manual wiring. The probes below remain > useful for one-off event investigation when you need to enable/disable individual events. +> **Post-migration note (DPLAN-0184):** Production hooks now route through the bridge at +> `src/aipass/hooks/apps/handlers/bridges/claude.py`. Probes are independent of the bridge +> pipeline -- they wire directly into `~/.claude/settings.json` as standalone commands. +> The wiring examples below still work as-is. + This directory contains ping-response probe scripts for each Claude Code hook event type. -Probes are **opt-in** — they are never auto-wired. See below for how to enable them. +Probes are **opt-in** -- they are never auto-wired. See below for how to enable them. --- @@ -14,7 +19,7 @@ Probes are **opt-in** — they are never auto-wired. See below for how to enable Each `probe_*.py` script in this directory is a passive observer for one Claude Code hook event. When enabled in `settings.json`, a probe fires on its event, records a structured entry to -`last_ping.jsonl`, and exits 0 immediately — it never blocks execution. +`last_ping.jsonl`, and exits 0 immediately -- it never blocks execution. --- @@ -34,32 +39,32 @@ When enabled in `settings.json`, a probe fires on its event, records a structure ## How to enable probes (settings.json snippets) -Add any subset of the following to your `.claude/settings.json` `hooks` object. -**Replace `/path/to/AIPass` with your actual repo root.** +Add any subset of the following to your `~/.claude/settings.json` `hooks` object. +Use `$AIPASS_HOME` (set by provider settings) or replace with your actual repo root. ```json { "hooks": { "PreToolUse": [ - {"hooks": [{"type": "command", "command": "python3 /path/to/AIPass/.claude/hooks/probes/probe_pre_tool_use.py"}]} + {"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/probes/probe_pre_tool_use.py"}]} ], "PostToolUse": [ - {"hooks": [{"type": "command", "command": "python3 /path/to/AIPass/.claude/hooks/probes/probe_post_tool_use.py"}]} + {"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/probes/probe_post_tool_use.py"}]} ], "UserPromptSubmit": [ - {"hooks": [{"type": "command", "command": "python3 /path/to/AIPass/.claude/hooks/probes/probe_user_prompt_submit.py"}]} + {"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/probes/probe_user_prompt_submit.py"}]} ], "SubagentStop": [ - {"hooks": [{"type": "command", "command": "python3 /path/to/AIPass/.claude/hooks/probes/probe_subagent_stop.py"}]} + {"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/probes/probe_subagent_stop.py"}]} ], "PreCompact": [ - {"hooks": [{"type": "command", "command": "python3 /path/to/AIPass/.claude/hooks/probes/probe_pre_compact.py"}]} + {"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/probes/probe_pre_compact.py"}]} ], "Stop": [ - {"hooks": [{"type": "command", "command": "python3 /path/to/AIPass/.claude/hooks/probes/probe_stop.py"}]} + {"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/probes/probe_stop.py"}]} ], "Notification": [ - {"hooks": [{"type": "command", "command": "python3 /path/to/AIPass/.claude/hooks/probes/probe_notification.py"}]} + {"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/probes/probe_notification.py"}]} ] } } @@ -102,7 +107,7 @@ drone @seedgo hooks probe --matrix ## Notes -- `last_ping.jsonl` is gitignored — it is a live log file, not source. +- `last_ping.jsonl` is gitignored -- it is a live log file, not source. - Probes are opt-in. The AIPass repo does **not** auto-wire them into `settings.json`. - Each probe script contains its own `settings.json` snippet in its module docstring. -- Probes are pure stdlib Python — no aipass imports, no third-party packages. +- Probes are pure stdlib Python -- no aipass imports, no third-party packages. diff --git a/.claude/hooks/prompt_inject.sh b/.claude/hooks/prompt_inject.sh deleted file mode 100755 index 6954d81b..00000000 --- a/.claude/hooks/prompt_inject.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/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 - -# 5. Secret prompt (devpulse only — gitignored, silent when missing) -case "$PWD" in - *devpulse*) cat "$REPO/src/aipass/devpulse/.devpulse_secret.md" 2>/dev/null || true ;; -esac - diff --git a/.claude/hooks/stop_sound.py b/.claude/hooks/stop_sound.py deleted file mode 100644 index bc055656..00000000 --- a/.claude/hooks/stop_sound.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -# Version: 1.0.0 -"""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__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("Stop", "provider", __file__, main) diff --git a/.claude/hooks/subagent_stop_gate.py b/.claude/hooks/subagent_stop_gate.py deleted file mode 100644 index 2c59b495..00000000 --- a/.claude/hooks/subagent_stop_gate.py +++ /dev/null @@ -1,189 +0,0 @@ -#!/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 os -import sys -import subprocess -from pathlib import Path - - -def _find_repo_root() -> Path | None: - """Walk up from CWD or AIPASS_HOME to find the git repo root.""" - for start in (os.environ.get("AIPASS_HOME", ""), os.getcwd()): - p = Path(start) - while p != p.parent: - if (p / ".git").exists(): - return p - p = p.parent - return None - - -AIPASS_ROOT = _find_repo_root() - - -def _get_cwd_branch() -> str | None: - """Detect which branch directory (src/aipass/) the CWD is in.""" - cwd = Path.cwd().resolve() - if AIPASS_ROOT is None: - return None - src = AIPASS_ROOT / "src" / "aipass" - try: - rel = cwd.relative_to(src) - return rel.parts[0] if rel.parts else None - except ValueError: - return None - - -def get_modified_py_files() -> list[str]: - """Get Python files modified in the working tree, scoped to the CWD branch. - - Uses drone @git status (branch-scoped) instead of raw git to comply with - git_gate enforcement. Only returns .py files inside the current branch. - """ - if AIPASS_ROOT is None: - return [] - cwd_branch = _get_cwd_branch() - branch_dir = AIPASS_ROOT / "src" / "aipass" / cwd_branch if cwd_branch else None - if not branch_dir or not branch_dir.exists(): - return [] - try: - result = subprocess.run( - ["drone", "@git", "status"], capture_output=True, text=True, timeout=10, cwd=str(branch_dir) - ) - files = [] - for line in result.stdout.strip().split("\n"): - line = line.strip() - if not line or "file(s) changed" in line: - continue - parts = line.split(None, 1) - if len(parts) != 2: - continue - _, filepath = parts - if not filepath.endswith(".py") or filepath.startswith(".claude/"): - continue - full = AIPASS_ROOT / filepath - 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 AIPASS_ROOT is None: - return [] - 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 check_hook_readme_accountability() -> str | None: - """Check if hook files changed but README wasn't updated. Returns reminder or None.""" - if AIPASS_ROOT is None: - return None - cwd_branch = _get_cwd_branch() - branch_dir = AIPASS_ROOT / "src" / "aipass" / cwd_branch if cwd_branch else None - if not branch_dir or not branch_dir.exists(): - return None - try: - result = subprocess.run( - ["drone", "@git", "status", "--all"], - capture_output=True, - text=True, - timeout=10, - cwd=str(branch_dir), - ) - changed = [] - for line in result.stdout.strip().split("\n"): - line = line.strip() - if not line or "file(s) changed" in line: - continue - parts = line.split(None, 1) - if len(parts) == 2: - changed.append(parts[1]) - - hook_files_changed = any(f.startswith(".claude/hooks/") and f.endswith(".py") for f in changed) - readme_changed = ".claude/hooks/README.md" in changed - - if hook_files_changed and not readme_changed: - return ( - "Hook files were modified but .claude/hooks/README.md was not updated. " - "Consider updating the README to reflect your changes." - ) - except Exception: - pass - return None - - -def main(): - try: - json.load(sys.stdin) - - modified = get_modified_py_files() - if not modified: - return # Nothing to check - - readme_reminder = check_hook_readme_accountability() - - all_violations = {} - for f in modified: - vs = run_seedgo_checklist(f) - if vs: - name = Path(f).name - all_violations[name] = vs - - if all_violations: - # 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.") - - if readme_reminder: - lines.append(f"\n⚠️ {readme_reminder}") - - output = {"decision": "block", "reason": "\n".join(lines)} - print(json.dumps(output)) - elif readme_reminder: - output = {"decision": "allow", "reason": f"⚠️ {readme_reminder}"} - print(json.dumps(output)) - - except Exception: - pass # Silent fail — don't block on errors - - -if __name__ == "__main__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("SubagentStop", "provider", __file__, main) diff --git a/.claude/hooks/tool_use_sound.py b/.claude/hooks/tool_use_sound.py deleted file mode 100644 index 9855c675..00000000 --- a/.claude/hooks/tool_use_sound.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python3 -# Version: 1.0.0 -"""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__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("PreToolUse", "provider", __file__, main) diff --git a/SECURITY.md b/SECURITY.md index 7cd7cf16..10a34fd2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,7 +37,7 @@ Instead, use one of these methods: - AIPass Python package (`src/aipass/`) - CLI entry points (`drone`, `aipass`) -- Hook scripts (`.claude/hooks/`) +- Hook handlers (`src/aipass/hooks/apps/handlers/`) - GitHub Actions workflows (`.github/workflows/`) ### Out of scope @@ -53,4 +53,4 @@ AIPass runs locally. No data leaves your machine unless you explicitly configure - **Secrets** are stored outside the repo at `~/.secrets/aipass/` and never committed - **API keys** are handled by the `api` branch and never logged or exposed in output - **Git operations** are sandboxed through `drone @git` with permission deny lists -- **Hook scripts** run in the Claude Code sandbox environment +- **Hook handlers** are native Python handlers routed through the hook engine diff --git a/setup.sh b/setup.sh index 7d9e19a3..8bd7c6e0 100755 --- a/setup.sh +++ b/setup.sh @@ -499,82 +499,82 @@ fi # --- Install Claude Code hooks --- CLAUDE_SETTINGS="$HOME/.claude/settings.json" -if [ -d "$SCRIPT_DIR/.claude/hooks" ]; then +# Determine python command for non-Claude provider hooks (Gemini, etc). +# Claude hooks use bridge pattern with $AIPASS_HOME env var — no HOOK_PYTHON needed. +# Linux: keep "python3" — distros ship 3.10+ and hooks import nothing +# version-specific beyond that. +# macOS: stock /usr/bin/python3 is 3.9.6 on macOS 12 and cannot parse +# scripts that use PEP 604 union syntax (`X | None`). Use the venv python. +# Windows: existing venv-python behavior. +if [ "$IS_WINDOWS" -eq 1 ]; then + HOOK_PYTHON="$SCRIPT_DIR/.venv/Scripts/python.exe" +elif [ "$IS_MACOS" -eq 1 ]; then + HOOK_PYTHON="$SCRIPT_DIR/.venv/bin/python3" +else + HOOK_PYTHON="python3" +fi + +if [ -f "$SCRIPT_DIR/src/aipass/hooks/apps/handlers/bridges/claude.py" ]; then echo "Installing Claude Code hooks ..." mkdir -p "$HOME/.claude" - # Determine python command for hooks. - # Linux: keep "python3" — distros ship 3.10+ and hooks import nothing - # version-specific beyond that. Leaving this path unchanged per Linux stability. - # macOS: stock /usr/bin/python3 is 3.9.6 on macOS 12 and cannot parse hook - # scripts that use PEP 604 union syntax (`X | None`). Point at the venv - # python, which setup just built with a 3.10+ interpreter. - # Windows: existing venv-python behavior. - if [ "$IS_WINDOWS" -eq 1 ]; then - HOOK_PYTHON="$SCRIPT_DIR/.venv/Scripts/python.exe" - elif [ "$IS_MACOS" -eq 1 ]; then - HOOK_PYTHON="$SCRIPT_DIR/.venv/bin/python3" - else - HOOK_PYTHON="python3" - fi - - "$PYTHON" - "$SCRIPT_DIR" "$CLAUDE_SETTINGS" "$HOOK_PYTHON" << 'PYEOF' + "$PYTHON" - "$SCRIPT_DIR" "$CLAUDE_SETTINGS" << 'PYEOF' import json +import os import sys from pathlib import Path repo_root = sys.argv[1] settings_path = Path(sys.argv[2]) -hook_python = sys.argv[3] -hooks_dir = f"{repo_root}/.claude/hooks" + +# Bridge entry point — all hooks route through the engine via this bridge. +# Uses $AIPASS_HOME env var (injected into settings.env below) so the +# settings file stays relocatable. +bridge = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py" # Load existing settings or start fresh if settings_path.exists(): - settings = json.loads(settings_path.read_text()) + settings = json.loads(settings_path.read_text(encoding="utf-8")) else: settings = {} -# Build hooks config with absolute paths +# Build hooks config — bridge pattern +# UserPromptSubmit: 4 separate entries (EventType:hook_name) to avoid output merging +# PreToolUse, PostToolUse, SubagentStop, Stop, Notification: single aggregate entries +# PreCompact: 2 hooks x 2 matchers (manual + auto) = 4 entries settings["hooks"] = { "UserPromptSubmit": [ - {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/global_prompt_loader.py"}]}, - {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/branch_prompt_loader.py"}]}, - {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/identity_injector.py"}]}, - {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/email_notification.py"}]}, + {"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:global_prompt"}]}, + {"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:branch_prompt"}]}, + {"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:identity_injector"}]}, + {"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:email_notification"}]}, ], "PreToolUse": [ {"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", - "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/tool_use_sound.py"}]}, - {"matcher": "Edit|MultiEdit|Write|NotebookEdit", - "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_edit_gate.py"}]}, - {"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", - "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/git_gate.py"}]}, + "hooks": [{"type": "command", "command": f"{bridge} PreToolUse"}]}, ], "PostToolUse": [ - {"matcher": "Edit|MultiEdit|Write|NotebookEdit", - "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_fix_diagnostics.py"}]}, - {"matcher": "Bash", - "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_watchdog.py"}]}, - ], - "Stop": [ - {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/stop_sound.py"}]}, - ], - "Notification": [ - {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/notification_sound.py"}]}, + {"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", + "hooks": [{"type": "command", "command": f"{bridge} PostToolUse"}]}, ], "SubagentStop": [ - {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/subagent_stop_gate.py"}]}, + {"hooks": [{"type": "command", "command": f"{bridge} SubagentStop"}]}, + ], + "Stop": [ + {"hooks": [{"type": "command", "command": f"{bridge} Stop"}]}, + ], + "Notification": [ + {"hooks": [{"type": "command", "command": f"{bridge} Notification"}]}, ], "PreCompact": [ - {"matcher": "manual", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact.py", "timeout": 60}]}, - {"matcher": "auto", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact.py", "timeout": 60}]}, - {"matcher": "manual", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact_rollover.py", "timeout": 120}]}, - {"matcher": "auto", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact_rollover.py", "timeout": 120}]}, + {"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact", "timeout": 60}]}, + {"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact", "timeout": 60}]}, + {"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact_rollover", "timeout": 120}]}, + {"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact_rollover", "timeout": 120}]}, ], } # Inject AIPASS_HOME into env block so dispatched agents find AIPass -import os env_block = settings.get("env", {}) env_block["AIPASS_HOME"] = repo_root env_block["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1" @@ -643,12 +643,12 @@ permissions["ask"] = ask settings["permissions"] = permissions -settings_path.write_text(json.dumps(settings, indent=2) + "\n") +settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") print(f" hooks -> {settings_path}") print(f" AIPASS_HOME -> {repo_root} (in settings.json env)") PYEOF else - echo "Skipping hooks (no .claude/hooks/ directory found)" + echo "Skipping Claude hooks (bridge not found at src/aipass/hooks/apps/handlers/bridges/claude.py)" fi # --- Install Claude Code commands (provider level) --- diff --git a/src/aipass/drone/README.md b/src/aipass/drone/README.md index 6db1fbd3..36923d64 100644 --- a/src/aipass/drone/README.md +++ b/src/aipass/drone/README.md @@ -55,6 +55,7 @@ drone @git pr "desc" # Push current branch and create PR to main drone @git dev-pr "desc" # Push dev and create PR to main drone @git merge # Merge a PR and sync local main drone @git delete-branch # Delete a remote branch (not main/dev) +drone @git close-pr # Close a PR by number drone @git branches # List remote branches drone @git sync # Pull latest (branch-aware: main or dev) drone @git sync --autostash # Sync with autostash for dirty trees @@ -173,6 +174,7 @@ drone/ │ │ ├── dev_pr_handler.py # Push dev and create PR to main │ │ ├── branches_handler.py # List remote branches │ │ ├── delete_branch_handler.py # Delete remote branch (main/dev protected) +│ │ ├── close_pr_handler.py # Close PR by number (gh pr close) │ │ ├── status_handler.py # Scoped git status (subprocess) │ │ └── sync_handler.py # Safe main sync (--autostash support) │ └── plugins/ @@ -216,7 +218,7 @@ Auth centralized via `verify_git_access()` in `apps/plugins/devpulse_ops/auth.py | Tier | Who | Commands | |------|-----|----------| | **Global** | All branches | `status`, `diff`, `log`, `lock`, `branches`, `issue`, `run`, `workflow` | -| **Owner** | `devpulse` only | `pr`, `commit`, `checkout`, `dev-pr`, `delete-branch`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` | +| **Owner** | `devpulse` only | `pr`, `commit`, `checkout`, `dev-pr`, `delete-branch`, `close-pr`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` | - Auth is checked once at the top of `git_module.handle_command()` before any handler is called - Unauthorized commands return a clear "Access denied" message with the caller's tier @@ -230,7 +232,7 @@ All work happens on `dev`. Only devpulse has write access. Agents build and repo **`pr` vs `dev-pr`:** `pr` works from any branch — on main it auto-creates a temp branch from the description slug (`main:`), on other branches it pushes directly. Does NOT use `-u` so main's upstream tracking stays on `origin/main`. `dev-pr` is specific to the dev→main workflow. Enforcement layers: -- `git_gate.py` PreToolUse hook blocks ALL raw git/gh commands +- Git gate (PreToolUse hook) blocks ALL raw git/gh commands - Drone tier system restricts write commands to devpulse only - Prompt instructions tell agents they have zero git access diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index a36fd30f..f3a7f523 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -567,6 +567,8 @@ def get_help(command: str | None = None) -> str: return ( "git delete-branch — Delete a remote branch [owner]\n Protected: main and dev cannot be deleted.\n" ) + if command == "close-pr": + return "git close-pr — Close a GitHub pull request by number [owner]\n" if command == "commit": return ( "git commit [--all | file1 file2 ...] — Commit changes [owner]\n" @@ -625,6 +627,7 @@ def get_help(command: str | None = None) -> str: " pr Push current branch and create PR to main\n" " dev-pr Push dev and create PR to main\n" " delete-branch Delete a remote branch\n" + " close-pr Close a PR\n" " merge Merge a PR\n" " sync [--autostash] Checkout main and pull\n" " smart-sync Fetch + rebase if behind\n" @@ -649,6 +652,7 @@ def get_introspective() -> str: " - dev_pr_handler.py (create_branch_pr, create_dev_pr — PR to main)\n" " - branches_handler.py (list_remote_branches)\n" " - delete_branch_handler.py (delete_remote_branch — protected: main/dev)\n" + " - close_pr_handler.py (close_pr — close PR by number)\n" "\n" " plugins/devpulse_ops/\n" " - auth.py (verify_git_access — tier-based authorization)\n" @@ -659,7 +663,7 @@ def get_introspective() -> str: " gh passthrough:\n" " - issue, run, workflow → subprocess gh [args]\n" "\n" - "Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (pr, commit, checkout, dev-pr, delete-branch, sync, unlock, merge, smart-sync, fix)\n" + "Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (pr, commit, checkout, dev-pr, delete-branch, close-pr, sync, unlock, merge, smart-sync, fix)\n" ) diff --git a/src/aipass/drone/apps/modules/resolver.py b/src/aipass/drone/apps/modules/resolver.py index 960e2907..7a366637 100644 --- a/src/aipass/drone/apps/modules/resolver.py +++ b/src/aipass/drone/apps/modules/resolver.py @@ -163,6 +163,8 @@ def resolve_branch(symbolic_name: str) -> str: branch_path = Path(branch["path"]) project_root = get_registry_path().parent + if not branch_path.is_absolute(): + branch_path = project_root / branch_path if not _validate_branch_path(branch_path, project_root, name): raise BranchNotFoundError(f"Branch '{symbolic_name}' path escapes project root — blocked for security") diff --git a/src/aipass/hooks/.aipass/aipass_local_prompt.md b/src/aipass/hooks/.aipass/aipass_local_prompt.md index b6e0a91e..47ee3c66 100644 --- a/src/aipass/hooks/.aipass/aipass_local_prompt.md +++ b/src/aipass/hooks/.aipass/aipass_local_prompt.md @@ -1,87 +1,107 @@ -# HOOKS — Branch Prompt +# HOOKS -- Branch Prompt - - -*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories, STATUS.local.md.* +Injected every turn. Breadcrumbs only -- details in README, --help, .trinity/, STATUS.local.md. ## Identity -*One line. Who you are and what your role is. This is the first thing the agent reads every turn — make it count.* - -You are HOOKS — {one-line role description}. +HOOKS -- hook infrastructure owner. Single engine dispatches all hooks across platforms (Claude, Codex, Gemini) with per-project config, full logging, and crash isolation. Builder citizen. The 13th citizen. ## What I Do -*3-5 bullets covering what happens in this branch. Not a mission statement — concrete actions. Think "if someone asked what this branch does day-to-day, what would you say?"* +- Own the hook engine -- receives events from platform bridges, routes to handlers, logs everything +- Maintain 14 native handlers across 4 categories (prompt, security, lifecycle, notification) +- Bridge platforms -- thin normalization layer per provider (Claude today, Codex/Gemini planned) +- Per-project config -- `.aipass/hooks.json` controls what fires per project +- Log everything -- prax integration + JSONL diagnostics for every hook execution -- {Primary responsibility} -- {Secondary responsibility} -- {What you build/maintain/operate} +## What I Don't Do + +- Touch provider settings directly -- setup.sh/doctor handles platform config installation +- Manage other branches -- I'm a builder, not an orchestrator +- Own handler business logic -- handlers are self-contained, engine just dispatches ## Key Commands -*The 5-8 commands you use most, with real arguments. Not your full command list — just the ones you'd need in 80% of sessions. Always show the full `drone @branch command [args]` syntax.* - ``` -drone @hooks {command1} [args] # What it does -drone @hooks {command2} [args] # What it does +drone @hooks status # Show hook config for current project +drone @hooks log # Tail recent hook activity (last 20 JSONL entries) +drone @hooks test # Run hook test suite (planned) +drone @hooks --help # Full help reference +drone @hooks --version # Version info ``` ## Architecture -*Your directory tree showing the code layout. Helps the agent find things without guessing. Skip this section entirely if your branch has no apps/ directory.* - ``` apps/ -├── hooks.py # Entry point -├── modules/ -│ ├── {module1}.py # What it orchestrates -│ └── {module2}.py # What it orchestrates -└── handlers/ - ├── {domain1}/ # What it handles - └── {domain2}/ # What it handles + hooks.py # Entry point (drone @hooks) + modules/ + engine.py # Core dispatch -- routes events to handlers + handlers/ + bridges/ + claude.py # Claude Code bridge (provider settings entry point) + prompt/ # Prompt injection hooks + branch_loader.py # Injects aipass_local_prompt.md + global_loader.py # Injects global prompt + identity.py # Injects passport identity block + security/ # Enforcement hooks + edit_gate.py # Blocks edits while type errors exist + git_gate.py # Enforces git access tiers + subagent_gate.py # Blocks sub-agent stop until clean + lifecycle/ # Session management hooks + auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile) + auto_watchdog.py # Watchdog arming after dispatch + compact.py # Pre-compact memory archival + rollover.py # Pre-compact memory rollover + notification/ # Alert hooks + announce.py # Inbox banner on prompt + email.py # Email notification + stop_sound.py # Sound on session stop + tool_sound.py # Sound on tool use + config/ + loader.py # hooks.json discovery + validation + diagnostics.py # Diagnostics config +logs/ + engine.jsonl # JSONL diagnostics (every hook execution) +tests/ # 15 test files, 244 tests ``` +## Handler Categories + +| Category | Count | Handlers | +|----------|-------|----------| +| prompt | 3 | branch_loader, global_loader, identity | +| security | 3 | edit_gate, git_gate, subagent_gate | +| lifecycle | 4 | auto_fix, auto_watchdog, compact, rollover | +| notification | 4 | announce, email, stop_sound, tool_sound | + +## How It Works + +1. Provider settings point ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`) +2. Bridge calls `engine.dispatch(event_type, stdin_data, config)` +3. Engine reads `.aipass/hooks.json` (walks up from CWD) +4. Engine runs matching hooks sequentially, logs each to JSONL +5. `{"decision": "block"}` with exit code 2 = block the action +6. Exit code 2 without JSON = crash (log error, continue to next hook) +7. All hook stdout concatenated and returned to platform + ## Integration -*Which branches you depend on or serve. Every branch connects to others — document those relationships so the agent knows who to ask and who's asking.* - -- **Depends on:** @{branch} for {what}, @{branch} for {what} -- **Serves:** @{branch} uses my {feature}, @{branch} calls my {command} +- **Depends on:** @prax for logging (system_logger for prax monitor visibility) +- **Serves:** All branches via hook dispatch -- every Claude Code session routes through the engine +- **Standards:** @seedgo audits handler code quality +- **Orchestration:** @devpulse dispatches build tasks to this branch ## Working Habits -*Behavioral patterns specific to this branch. How you approach work differently from other branches. Decision frameworks, common workflows, domain-specific patterns. Only include habits that are unique to this branch — if it applies to all branches, it's in the global prompt.* - -- {Habit or pattern that shapes how you work} -- {Decision framework or workflow unique to this domain} +- Handlers are self-contained. One file per hook, one test file per handler. No cross-handler imports. +- Crash isolation is non-negotiable. One broken hook never blocks the rest. Engine catches and logs. +- Bridge layer stays thin. Normalization only -- no business logic in bridges. +- Test everything in isolation. Handlers should be testable without the engine, engine without handlers. +- Config walks up. `.aipass/hooks.json` is discovered by walking CWD upward, not hardcoded paths. ## Known Gotchas -*Non-obvious quirks, hard-won lessons, things that will waste 20 minutes if you don't know them. These are the breadcrumbs that save time — the stuff you'd tell a new agent on day one.* - -- {Gotcha or non-obvious behavior} -- {Hard-won lesson from a past session} +- Exit code 2 has dual meaning: intentional block (with JSON) vs crash (without JSON). Engine distinguishes by checking stdout. +- JSONL log lives at `logs/engine.jsonl` -- not in prax. Prax gets a copy via system_logger, but JSONL is the source of truth for hook diagnostics. +- Bridge must be the ONLY entry in provider settings per event type. Multiple entries per event = platform calls them all independently, bypassing engine sequencing. diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index 858ee861..51ac2d3c 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -2,7 +2,7 @@ "metadata": { "version": "1.1.0", "created": "2026-05-18", - "updated": "2026-05-19", + "updated": "2026-05-21", "description": "Standards bypass configuration for this branch" }, "bypass": [ @@ -110,6 +110,286 @@ "file": "tests/test_engine.py", "standard": "exception_contracts", "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns" + }, + { + "file": "apps/handlers/notification/announce.py", + "standard": "json_structure", + "reason": "Sound handler — no JSON operations, plays WAV files" + }, + { + "file": "apps/handlers/notification/stop_sound.py", + "standard": "json_structure", + "reason": "Sound handler — no JSON operations, plays WAV files" + }, + { + "file": "tests/test_stop_sound.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_stop_sound.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_stop_sound.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_stop_sound.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" + }, + { + "file": "tests/test_announce.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_announce.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_announce.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_announce.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" + }, + { + "file": "apps/handlers/notification/email.py", + "standard": "json_structure", + "reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler" + }, + { + "file": "apps/handlers/lifecycle/auto_watchdog.py", + "standard": "json_structure", + "reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler" + }, + { + "file": "apps/handlers/lifecycle/auto_fix.py", + "standard": "json_structure", + "reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler" + }, + { + "file": "apps/handlers/security/edit_gate.py", + "standard": "json_structure", + "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler" + }, + { + "file": "apps/handlers/security/git_gate.py", + "standard": "json_structure", + "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler" + }, + { + "file": "apps/handlers/security/subagent_gate.py", + "standard": "json_structure", + "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler" + }, + { + "file": "apps/handlers/security/subagent_gate.py", + "standard": "open_encoding", + "reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio" + }, + { + "file": "tests/test_email.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_email.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_email.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_email.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" + }, + { + "file": "tests/test_subagent_gate.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_subagent_gate.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_subagent_gate.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_subagent_gate.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" + }, + { + "file": "tests/test_auto_fix.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_auto_fix.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_auto_fix.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_auto_fix.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" + }, + { + "file": "tests/test_auto_fix.py", + "standard": "commented_logger", + "reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test — not a commented-out call" + }, + { + "file": "tests/test_auto_fix.py", + "standard": "trigger", + "reason": "Test cleanup .unlink() removes temporary state files — not a production file deletion" + }, + { + "file": "apps/handlers/prompt/identity.py", + "standard": "json_structure", + "reason": "Uses stdlib json.loads to read passport.json — no JSON file ops needing json_handler" + }, + { + "file": "tests/test_identity.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_identity.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_identity.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_identity.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" + }, + { + "file": "apps/handlers/prompt/branch_loader.py", + "standard": "json_structure", + "reason": "No JSON operations — reads markdown files and outputs text" + }, + { + "file": "tests/test_branch_loader.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_branch_loader.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_branch_loader.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_branch_loader.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" + }, + { + "file": "apps/handlers/prompt/global_loader.py", + "standard": "json_structure", + "reason": "No JSON operations — reads markdown file and outputs text" + }, + { + "file": "tests/test_global_loader.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_global_loader.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_global_loader.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_global_loader.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" + }, + { + "file": "apps/handlers/lifecycle/compact.py", + "standard": "json_structure", + "reason": "Uses stdlib json.loads for local.json reading — no JSON file ops needing json_handler" + }, + { + "file": "apps/handlers/lifecycle/rollover.py", + "standard": "json_structure", + "reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler" + }, + { + "file": "tests/test_compact.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_compact.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_compact.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_compact.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" + }, + { + "file": "tests/test_rollover.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_rollover.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_rollover.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_rollover.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" } ], "notes": { diff --git a/src/aipass/hooks/apps/handlers/bridges/claude.py b/src/aipass/hooks/apps/handlers/bridges/claude.py index d5a9bd51..69869cd5 100644 --- a/src/aipass/hooks/apps/handlers/bridges/claude.py +++ b/src/aipass/hooks/apps/handlers/bridges/claude.py @@ -14,7 +14,9 @@ Claude Code bridge. Thin entry point called from ~/.claude/settings.json hook entries. Normalizes Claude Code's stdin/stdout format and calls the engine. -Called from provider settings as the sole hook entry point per event type. +Supports two forms: + claude.py EventType — dispatch ALL enabled hooks for that event + claude.py EventType:hook_name — dispatch ONLY that one hook (separate output) """ import sys @@ -26,10 +28,15 @@ from aipass.prax.apps.modules.logger import system_logger as logger def main() -> None: """Entry point — receive event type from Claude Code, dispatch via engine.""" if len(sys.argv) < 2: - sys.stderr.write("Usage: claude.py \n") + sys.stderr.write("Usage: claude.py or claude.py \n") sys.exit(1) - event_type = sys.argv[1] + arg = sys.argv[1] + hook_filter = None + if ":" in arg: + event_type, hook_filter = arg.split(":", 1) + else: + event_type = arg stdin_data = "" if not sys.stdin.isatty(): @@ -40,6 +47,11 @@ def main() -> None: config = {"hooks_enabled": True} logger.info("[HOOKS:claude] no project config found, using defaults") + if hook_filter: + full_config: dict = config + hook_def = full_config.get(event_type, {}).get(hook_filter, {}) + config = {"hooks_enabled": True, event_type: {hook_filter: hook_def}} + output = dispatch(event_type, stdin_data, config) if output: sys.stdout.write(output) diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py new file mode 100644 index 00000000..df635acc --- /dev/null +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py @@ -0,0 +1,392 @@ +# =================== AIPass ==================== +# Name: auto_fix.py +# Version: 1.0.0 +# Description: Post-edit diagnostics — syntax, lint, type, pattern, seedgo checks (PostToolUse) +# Branch: hooks +# Layer: apps/handlers/lifecycle +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Runs diagnostics on edited files and surfaces errors for the agent to fix.""" + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.info("[HOOKS] auto_fix: speak error: %s", exc) + + +EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} +STATE_FILE = Path(__file__).parent.parent.parent.parent.parent / ".diagnostics_state.json" +SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"} + +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_CORRUPTION_CHARS = ["�", "\x00"] + + +def _check_syntax(file_path: str) -> list[str]: + try: + result = subprocess.run( + [sys.executable, "-m", "py_compile", file_path], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + return [f"SYNTAX: {result.stderr.strip()}"] + except Exception as exc: + logger.info("[HOOKS] auto_fix: py_compile failed: %s", exc) + return [] + + +def _check_ruff_lint(file_path: str) -> list[str]: + 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(): + return [f"LINT: {line}" for line in result.stdout.strip().split("\n")[:5]] + except FileNotFoundError: + logger.info("[HOOKS] auto_fix: ruff not found") + except Exception as exc: + logger.info("[HOOKS] auto_fix: ruff lint failed: %s", exc) + return [] + + +def _check_ruff_format(file_path: str) -> list[str]: + try: + result = subprocess.run( + ["ruff", "format", "--check", file_path], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + name = Path(file_path).name + return [f"FORMAT: {name} needs ruff format (run: ruff format {name})"] + except FileNotFoundError: + logger.info("[HOOKS] auto_fix: ruff not found") + except Exception as exc: + logger.info("[HOOKS] auto_fix: ruff format check failed: %s", exc) + return [] + + +def _check_line_pattern(line: str, pattern: str) -> bool: + stripped = line.strip() + if stripped.startswith(("#", '"', "'")): + return False + if f'"{pattern}' in line or f"'{pattern}" in line: + return False + return pattern in line + + +def _check_patterns(file_path: str) -> list[str]: + errors: list[str] = [] + 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") + + if requires_missing: + if pattern in content and requires_missing not in content: + errors.append(f"PATTERN: {message}") + continue + + for line in lines: + if _check_line_pattern(line, pattern): + errors.append(f"PATTERN: {message}") + break + except Exception as exc: + logger.info("[HOOKS] auto_fix: pattern check failed: %s", exc) + return errors + + +def _run_python_checks(file_path: str) -> list[str]: + errors: list[str] = [] + errors.extend(_check_syntax(file_path)) + errors.extend(_check_ruff_lint(file_path)) + errors.extend(_check_ruff_format(file_path)) + errors.extend(_check_patterns(file_path)) + return errors + + +def _run_ruff_lint_structured(file_path: str) -> list[dict]: + if "/.claude/hooks/" in file_path: + return [] + try: + result = subprocess.run( + ["ruff", "check", "--select=E,F,W", "--output-format=json", file_path], + capture_output=True, + text=True, + timeout=10, + ) + if not result.stdout.strip(): + return [] + violations = json.loads(result.stdout) + if not isinstance(violations, list): + return [] + errors: list[dict] = [] + for v in violations[:10]: + line = v.get("location", {}).get("row", 0) + code = v.get("code", "?") + message = v.get("message", "unknown")[:100] + errors.append({"line": line, "message": f"{code}: {message}"}) + return errors + except FileNotFoundError: + logger.info("[HOOKS] auto_fix: ruff not found for structured lint") + except json.JSONDecodeError as exc: + logger.info("[HOOKS] auto_fix: ruff JSON parse failed: %s", exc) + except subprocess.TimeoutExpired: + logger.info("[HOOKS] auto_fix: ruff structured lint timed out") + except Exception as exc: + logger.info("[HOOKS] auto_fix: ruff structured lint failed: %s", exc) + return [] + + +def _run_pyright_check(file_path: str) -> list[dict]: + if "/.claude/hooks/" in file_path: + return [] + try: + result = subprocess.run( + [sys.executable, "-m", "pyright", "--outputjson", file_path], + capture_output=True, + text=True, + timeout=15, + ) + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, ValueError) as exc: + logger.info("[HOOKS] auto_fix: pyright JSON parse failed: %s", exc) + return [] + + errors: list[dict] = [] + for diag in data.get("generalDiagnostics", []): + if diag.get("severity", "") == "error": + line = diag.get("range", {}).get("start", {}).get("line", 0) + message = diag.get("message", "Unknown error") + errors.append({"line": line, "message": message[:100]}) + return errors[:10] + except FileNotFoundError: + logger.info("[HOOKS] auto_fix: pyright not installed") + except subprocess.TimeoutExpired: + logger.info("[HOOKS] auto_fix: pyright timed out") + except Exception as exc: + logger.info("[HOOKS] auto_fix: pyright failed: %s", exc) + return [] + + +def _run_seedgo_checklist(file_path: str) -> list[str]: + if "/.claude/hooks/" in file_path: + return [] + aipass_home = os.environ.get("AIPASS_HOME", "") + if not aipass_home: + return [] + try: + result = subprocess.run( + ["drone", "@seedgo", "checklist", file_path], + capture_output=True, + text=True, + timeout=15, + cwd=aipass_home, + ) + if result.returncode != 0: + return [] + violations: list[str] = [] + for line in result.stdout.split("\n"): + line = line.strip() + if line.startswith("✗"): + violation = line[1:].strip() + if violation: + violations.append(violation) + return violations[:5] + except FileNotFoundError: + logger.info("[HOOKS] auto_fix: drone not found for seedgo checklist") + except Exception as exc: + logger.info("[HOOKS] auto_fix: seedgo checklist failed: %s", exc) + return [] + + +def _save_diagnostics_state(file_path: str, errors: list[dict]) -> None: + try: + if errors: + state = {"file": str(Path(file_path).resolve()), "errors": errors} + STATE_FILE.write_text(json.dumps(state), encoding="utf-8") + else: + if STATE_FILE.exists(): + STATE_FILE.unlink() + except Exception as exc: + logger.info("[HOOKS] auto_fix: state file write failed: %s", exc) + + +def _check_emoji_list(items: list, key: str) -> str | None: + for item in items: + if not isinstance(item, str) or len(item) != 1: + continue + if ord(item) < 128 and item not in "✓✗": + return f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}" + return None + + +def _run_json_checks(file_path: str) -> list[str]: + errors: list[str] = [] + try: + content = Path(file_path).read_text(encoding="utf-8") + except Exception as e: + logger.info("[HOOKS] auto_fix: json read failed: %s", e) + return [f"READ ERROR: {e!s}"] + + for char in JSON_CORRUPTION_CHARS: + if char in content: + errors.append(f"EMOJI CORRUPTION: Found corrupted character '{char!r}'") + break + + try: + data = json.loads(content) + except json.JSONDecodeError as e: + logger.info("[HOOKS] auto_fix: json syntax error in %s: %s", file_path, e) + errors.append(f"JSON SYNTAX: {e.msg} at line {e.lineno}") + return errors + + if not isinstance(data, dict): + return errors + + for key in ("allowed_emojis", "emojis", "emoji_list"): + values = data.get(key) + if not isinstance(values, list): + continue + finding = _check_emoji_list(values, key) + if finding: + errors.append(finding) + + return errors + + +def handle(hook_data: dict) -> dict: + """Run diagnostics on edited files and surface errors. + + Args: + hook_data: Parsed hook event dict from engine. + + Returns: + Result dict with stdout (JSON additionalContext or empty) and exit_code. + """ + try: + tool_name = hook_data.get("tool_name", "") + if tool_name not in EDIT_TOOLS: + return {"stdout": "", "exit_code": 0} + + tool_input = hook_data.get("tool_input", {}) + file_path = tool_input.get("file_path", "") + if not file_path: + return {"stdout": "", "exit_code": 0} + + ext = Path(file_path).suffix.lower() + if ext in SKIP_EXTENSIONS: + return {"stdout": "", "exit_code": 0} + + _speak("auto fix diagnostics") + + errors: list[str] = [] + + if file_path.endswith(".py"): + errors = _run_python_checks(file_path) + + seedgo_violations = _run_seedgo_checklist(file_path) + for v in seedgo_violations: + errors.append(f"SEEDGO: {v}") + + type_errors = _run_pyright_check(file_path) + for te in type_errors: + errors.append(f"TYPE: L{te['line']}: {te['message']}") + + ruff_lint_errors = _run_ruff_lint_structured(file_path) + _save_diagnostics_state(file_path, ruff_lint_errors + type_errors) + + elif file_path.endswith(".json"): + errors = _run_json_checks(file_path) + else: + return {"stdout": "", "exit_code": 0} + + if errors: + error_text = "\n".join(f" - {e}" for e in errors) + context = ( + f"[AUTO-FIX] {len(errors)} error(s) in {Path(file_path).name}:\n" + f"{error_text}\n\n" + f"Fix these errors in {Path(file_path).name} now. Do not skip or defer." + ) + result = { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": context, + }, + "systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing", + } + return {"stdout": json.dumps(result), "exit_code": 0} + + result = {"systemMessage": "[diagnostics] ok"} + return {"stdout": json.dumps(result), "exit_code": 0} + + except Exception as exc: + logger.info("[HOOKS] auto_fix: unexpected error (allowing): %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py new file mode 100644 index 00000000..deb56bb0 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py @@ -0,0 +1,77 @@ +# =================== AIPass ==================== +# Name: auto_watchdog.py +# Version: 1.0.0 +# Description: Reminds agent to arm watchdog after dispatch (PostToolUse) +# Branch: hooks +# Layer: apps/handlers/lifecycle +# Created: 2026-05-21 +# Modified: 2026-05-21 +# ============================================= + +"""Checks for dispatch commands and reminds the agent to arm the watchdog.""" + +import json +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.info("[HOOKS] auto_watchdog: speak error: %s", exc) + + +def handle(hook_data: dict) -> dict: + """Return additionalContext reminder if dispatch detected without watchdog. + + Args: + hook_data: Parsed hook event dict from engine. + + Returns: + Result dict with stdout (JSON additionalContext or empty) and exit_code. + """ + tool_name = hook_data.get("tool_name", "") + if tool_name != "Bash": + return {"stdout": "", "exit_code": 0} + + command = hook_data.get("tool_input", {}).get("command", "") + + if "drone @ai_mail dispatch" not in command: + return {"stdout": "", "exit_code": 0} + + if "unread_count" in command and "while [" in command: + return {"stdout": "", "exit_code": 0} + + if "dispatch wake" in command and "dispatch @" not in command: + return {"stdout": "", "exit_code": 0} + + _speak("auto watchdog") + + result = { + "additionalContext": ( + "[AUTO-WATCHDOG] Dispatch detected — arm watchdog NOW. " + "Run the watchdog one-liner from your local prompt with " + "run_in_background: true and timeout: 600000." + ) + } + return {"stdout": json.dumps(result), "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/lifecycle/compact.py b/src/aipass/hooks/apps/handlers/lifecycle/compact.py new file mode 100644 index 00000000..0cc83ea4 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/lifecycle/compact.py @@ -0,0 +1,169 @@ +# =================== AIPass ==================== +# Name: compact.py +# Version: 1.0.0 +# Description: Injects live state for post-compact recovery (PreCompact) +# Branch: hooks +# Layer: apps/handlers/lifecycle +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Reads branch state and injects recovery context before compaction.""" + +import json +import os +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.info("[HOOKS] compact: speak error: %s", exc) + + +def _find_branch_dir(cwd: str) -> Path | None: + parts = Path(cwd).parts + for i, part in enumerate(parts): + if part == "aipass" and i > 0 and parts[i - 1] == "src": + branch_dir = Path(*parts[: i + 2]) + if branch_dir.is_dir(): + return branch_dir + if (Path(cwd) / ".trinity").is_dir(): + return Path(cwd) + return None + + +def _read_status_local(branch_dir: Path) -> str | None: + for name in ("STATUS.local.md", "dev.local.md"): + path = branch_dir / name + if path.is_file(): + try: + return path.read_text(encoding="utf-8")[:3000] + except Exception as exc: + logger.info("[HOOKS] compact: read status failed: %s", exc) + return None + + +def _read_last_session(branch_dir: Path) -> str | None: + local_path = branch_dir / ".trinity" / "local.json" + if not local_path.is_file(): + return None + try: + data = json.loads(local_path.read_text(encoding="utf-8")) + result: list[str] = [] + sessions = data.get("sessions", []) + if sessions: + last = sessions[0] + result.append( + f"Last session (#{last.get('id', '?')}, {last.get('d', '?')}): {last.get('sum', 'no summary')}" + ) + learnings = data.get("key_learnings", {}) + if learnings: + keys = list(learnings.keys())[-10:] + result.append(f"Key learnings available: {', '.join(keys)}") + return "\n".join(result) if result else None + except Exception as exc: + logger.info("[HOOKS] compact: read session failed: %s", exc) + return None + + +def _get_git_info() -> str | None: + try: + branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + timeout=5, + ) + dirty = subprocess.run( + ["git", "status", "--porcelain"], + capture_output=True, + text=True, + timeout=5, + ) + result: list[str] = [] + if branch.returncode == 0: + result.append(f"Git branch: {branch.stdout.strip()}") + if dirty.returncode == 0 and dirty.stdout.strip(): + lines = dirty.stdout.strip().split("\n") + result.append(f"Uncommitted changes: {len(lines)} files") + return "\n".join(result) if result else None + except Exception as exc: + logger.info("[HOOKS] compact: git info failed: %s", exc) + return None + + +def handle(hook_data: dict) -> dict: + """Inject live branch state for post-compact recovery.""" + _speak("pre compact") + + try: + cwd = hook_data.get("cwd", "") or str(Path.cwd()) + branch_dir = _find_branch_dir(cwd) + branch_name = branch_dir.name if branch_dir else "unknown" + + sections: list[str] = [] + sections.append( + f"POST-COMPACT RECOVERY — @{branch_name}\n\n" + "Context just compacted. Below is your live state. Use it to continue seamlessly." + ) + + git_info = _get_git_info() + if git_info: + sections.append(f"## Git\n{git_info}") + + if branch_dir: + session_info = _read_last_session(branch_dir) + if session_info: + sections.append(f"## Last Session\n{session_info}") + + status = _read_status_local(branch_dir) + if status: + sections.append(f"## STATUS.local.md\n{status}") + + is_dispatched = os.environ.get("AIPASS_SESSION_TYPE") == "dispatched" + if is_dispatched: + sections.append( + "## DISPATCHED AGENT — SAVE STATE NOW\n" + "Before continuing work, you MUST update your memories:\n" + "1. Update .trinity/local.json — add/update current session with work done so far\n" + "2. Update STATUS.local.md — ensure Current Work reflects what you've accomplished\n" + "3. Then continue your task from where the summary left off\n\n" + "This is non-optional. Compaction just happened — if you don't save now, work history is lost." + ) + else: + sections.append( + "## Recovery Protocol\n" + "- Continue where the summary left off — don't restart or ask generic questions\n" + "- .trinity/local.json has full session history and key_learnings — read it if you need more context\n" + "- STATUS.local.md has current work, known issues, and todos\n" + "- Save memories proactively — compaction just proved you need to\n" + "- Match the conversation tone from before compaction" + ) + + return {"stdout": "\n\n".join(sections), "exit_code": 0} + + except Exception as exc: + logger.info("[HOOKS] compact: unexpected error: %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/.claude/hooks/pre_compact_rollover.py b/src/aipass/hooks/apps/handlers/lifecycle/rollover.py old mode 100755 new mode 100644 similarity index 52% rename from .claude/hooks/pre_compact_rollover.py rename to src/aipass/hooks/apps/handlers/lifecycle/rollover.py index ebf4c4b6..be409362 --- a/.claude/hooks/pre_compact_rollover.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/rollover.py @@ -1,36 +1,61 @@ -#!/usr/bin/env python3 -""" -Pre-Compact Rollover Hook — check branch memory files and run rollover if overdue. +# =================== AIPass ==================== +# Name: rollover.py +# Version: 1.0.0 +# Description: Checks branch memory files and runs rollover if overdue (PreCompact) +# Branch: hooks +# Layer: apps/handlers/lifecycle +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= -Runs alongside pre_compact.py on PreCompact events. Scans all branches' -.trinity files for over-limit conditions and executes rollover via drone -if any are found. Stdout stays clean (pre_compact.py owns stdout for -context injection). All logging goes to stderr. - -Version: 1.0.0 -""" +"""Scans all branches for over-limit memory files and triggers rollover via drone.""" import json +import os import subprocess -import sys +import tempfile from pathlib import Path +from aipass.prax.apps.modules.logger import system_logger as logger -def _find_repo_root(): - """Find the AIPass repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.info("[HOOKS] rollover: speak error: %s", exc) + + +def _find_repo_root() -> Path | None: + aipass_home = os.environ.get("AIPASS_HOME", "") + if aipass_home: + p = Path(aipass_home) + if (p / "AIPASS_REGISTRY.json").exists(): + return p cwd = Path.cwd() - for parent in [cwd] + list(cwd.parents): + for parent in [cwd, *list(cwd.parents)]: if (parent / "AIPASS_REGISTRY.json").exists(): return parent return None -def _read_registry(repo_root): - """Read branch list from AIPASS_REGISTRY.json.""" +def _read_registry(repo_root: Path) -> list[dict]: registry_path = repo_root / "AIPASS_REGISTRY.json" if not registry_path.exists(): return [] @@ -44,31 +69,26 @@ def _read_registry(repo_root): resolved = repo_root / raw_path branch["_resolved_path"] = resolved return branches - except Exception: + except Exception as exc: + logger.info("[HOOKS] rollover: registry read failed: %s", exc) return [] -def _check_file(file_path): - """Check if a .trinity memory file is overdue for rollover. - - Returns (overdue: bool, description: str) or (False, "") if not overdue. - """ +def _check_file(file_path: Path) -> tuple[bool, str]: if not file_path.is_file(): return False, "" - try: raw = file_path.read_text(encoding="utf-8") data = json.loads(raw) - except Exception: + except Exception as exc: + logger.info("[HOOKS] rollover: file parse failed %s: %s", file_path, exc) return False, "" - metadata = data.get("document_metadata", {}) - schema_version = metadata.get("schema_version", "1.0.0") - limits = metadata.get("limits", {}) + limits = data.get("document_metadata", {}).get("limits", {}) has_v2_limits = any(k in limits for k in ("max_sessions", "max_key_learnings", "max_observations")) if has_v2_limits: - reasons = [] + reasons: list[str] = [] max_sessions = limits.get("max_sessions") if max_sessions is not None: sessions = data.get("sessions", []) @@ -91,7 +111,6 @@ def _check_file(file_path): return True, ", ".join(reasons) return False, "" - # v1: line-count based max_lines = limits.get("max_lines", 600) current_lines = raw.count("\n") + 1 if current_lines >= max_lines: @@ -99,28 +118,23 @@ def _check_file(file_path): return False, "" -def _find_overdue(repo_root): - """Scan all branches for overdue memory files. Returns list of (branch, type, reason).""" +def _find_overdue(repo_root: Path) -> list[tuple[str, str, str]]: branches = _read_registry(repo_root) - overdue = [] - + overdue: list[tuple[str, str, str]] = [] for branch in branches: name = branch.get("name", "unknown") branch_path = branch.get("_resolved_path") if not branch_path or not branch_path.is_dir(): continue - - for memory_type in ["local", "observations"]: + for memory_type in ("local", "observations"): file_path = branch_path / ".trinity" / f"{memory_type}.json" is_overdue, reason = _check_file(file_path) if is_overdue: overdue.append((name, memory_type, reason)) - return overdue -def _run_rollover(repo_root): - """Execute rollover via drone subprocess. Returns (success, output).""" +def _run_rollover(repo_root: Path) -> tuple[bool, str]: try: result = subprocess.run( ["drone", "@memory", "rollover", "run"], @@ -131,44 +145,37 @@ def _run_rollover(repo_root): ) return result.returncode == 0, result.stdout + result.stderr except subprocess.TimeoutExpired: + logger.info("[HOOKS] rollover: drone rollover timed out (110s)") return False, "Rollover timed out (110s)" - except Exception as e: - return False, str(e) + except Exception as exc: + logger.info("[HOOKS] rollover: drone rollover failed: %s", exc) + return False, str(exc) -def main(): - """Main hook entry point.""" - try: - json.load(sys.stdin) - except Exception: - pass +def handle(hook_data: dict) -> dict: + """Check memory files for overflow and trigger rollover if needed.""" + _speak("pre compact rollover") try: repo_root = _find_repo_root() if not repo_root: - sys.exit(0) + return {"stdout": "", "exit_code": 0} overdue = _find_overdue(repo_root) if not overdue: - sys.exit(0) + return {"stdout": "", "exit_code": 0} summary = "; ".join(f"{name}.{mtype} ({reason})" for name, mtype, reason in overdue) - print(f"Pre-compact rollover: {len(overdue)} overdue — {summary}", file=sys.stderr) + logger.info("[HOOKS] rollover: %d overdue — %s", len(overdue), summary) success, output = _run_rollover(repo_root) if success: - print(f"Pre-compact rollover: complete ({len(overdue)} files processed)", file=sys.stderr) + logger.info("[HOOKS] rollover: complete (%d files processed)", len(overdue)) else: - print(f"Pre-compact rollover: failed — {output[:200]}", file=sys.stderr) + logger.info("[HOOKS] rollover: failed — %s", output[:200]) - except Exception as e: - print(f"Pre-compact rollover error: {e}", file=sys.stderr) + return {"stdout": "", "exit_code": 0} - sys.exit(0) - - -if __name__ == "__main__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from hook_log import run_and_log - - run_and_log("PreCompact", "provider", __file__, main) + except Exception as exc: + logger.info("[HOOKS] rollover: unexpected error: %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/notification/announce.py b/src/aipass/hooks/apps/handlers/notification/announce.py new file mode 100644 index 00000000..a7458b0a --- /dev/null +++ b/src/aipass/hooks/apps/handlers/notification/announce.py @@ -0,0 +1,84 @@ +# =================== AIPass ==================== +# Name: announce.py +# Version: 1.1.0 +# Description: Plays announcement tone on Notification events +# Branch: hooks +# Layer: apps/handlers/notification +# Created: 2026-05-20 +# Modified: 2026-05-20 +# ============================================= + +"""Plays announcement tone + Piper voice ID on Notification events.""" + +import os +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", "")) +SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds" +SOUND_FILE = SOUNDS_DIR / "mixkit-clear-announce-tones-2861.wav" + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _play(sound_path: Path) -> None: + """Play a WAV file via aplay (fire-and-forget).""" + if not sound_path.exists(): + logger.info("[HOOKS] announce: file not found: %s", sound_path) + return + try: + subprocess.Popen( + ["aplay", "-q", str(sound_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError as exc: + logger.info("[HOOKS] announce: playback error: %s", exc) + + +def _speak(text: str) -> None: + """Generate speech via Piper TTS and play it (fire-and-forget).""" + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + logger.info("[HOOKS] announce: piper not available") + return + + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + + piper_result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + + if piper_result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen( + ["aplay", "-q", wav_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.TimeoutExpired: + logger.info("[HOOKS] announce: piper timed out") + except OSError as exc: + logger.info("[HOOKS] announce: speak error: %s", exc) + + +def handle(hook_data: dict) -> dict: + """Play notification tone and speak hook name for identification. + + Args: + hook_data: Parsed hook event dict from engine. + + Returns: + Result dict with stdout (empty) and exit_code. + """ + _speak("notification sound") + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/notification/email.py b/src/aipass/hooks/apps/handlers/notification/email.py new file mode 100644 index 00000000..97e4042e --- /dev/null +++ b/src/aipass/hooks/apps/handlers/notification/email.py @@ -0,0 +1,138 @@ +# =================== AIPass ==================== +# Name: email.py +# Version: 1.1.0 +# Description: Checks inbox for unread emails on UserPromptSubmit +# Branch: hooks +# Layer: apps/handlers/notification +# Created: 2026-05-21 +# Modified: 2026-05-21 +# ============================================= + +"""Checks branch inbox for unread emails and returns notification text.""" + +import json +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + """Generate speech via Piper TTS and play it (fire-and-forget).""" + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + logger.info("[HOOKS] email: piper not available") + return + + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + + piper_result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + + if piper_result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen( + ["aplay", "-q", wav_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.TimeoutExpired: + logger.info("[HOOKS] email: piper timed out") + except OSError as exc: + logger.info("[HOOKS] email: speak error: %s", exc) + + +def _find_branch_root() -> Path | None: + """Find the branch root by walking up from CWD looking for branch markers.""" + cwd = Path.cwd() + repo_root = _find_repo_root() + if not repo_root: + return None + + search = cwd + for _ in range(10): + has_trinity = (search / ".trinity").is_dir() + has_apps = (search / "apps").is_dir() + has_mail = (search / ".ai_mail.local").is_dir() or (search / "ai_mail.local").is_dir() + + if (has_trinity or has_apps or has_mail) and search != repo_root: + return search + + if search == repo_root: + break + + parent = search.parent + if parent == search: + break + search = parent + + return None + + +def _find_repo_root() -> Path | None: + """Find the repo root (contains pyproject.toml or .git).""" + search = Path.cwd() + while search.parent != search: + if (search / "pyproject.toml").exists() or (search / ".git").is_dir(): + return search + search = search.parent + return None + + +def _count_new_emails(branch_root: Path) -> int: + """Count unread emails in the branch's inbox.""" + inbox_path = branch_root / ".ai_mail.local" / "inbox.json" + if not inbox_path.exists(): + inbox_path = branch_root / "ai_mail.local" / "inbox.json" + + if not inbox_path.exists(): + return 0 + + try: + data = json.loads(inbox_path.read_text(encoding="utf-8")) + messages = data if isinstance(data, list) else data.get("messages", []) + count = 0 + for msg in messages: + if msg.get("status") == "new": + count += 1 + elif msg.get("status") is None and not msg.get("read", False): + count += 1 + return count + except (json.JSONDecodeError, OSError) as exc: + logger.info("[HOOKS] email: inbox read error: %s", exc) + return 0 + + +def handle(hook_data: dict) -> dict: + """Check inbox and return email notification if unread messages exist. + + Args: + hook_data: Parsed hook event dict from engine. + + Returns: + Result dict with stdout (notification text or empty) and exit_code. + """ + branch_root = _find_branch_root() + if not branch_root: + logger.info("[HOOKS] email: no branch root found") + return {"stdout": "", "exit_code": 0} + + new_count = _count_new_emails(branch_root) + if new_count == 0: + return {"stdout": "", "exit_code": 0} + + plural = "s" if new_count != 1 else "" + _speak(f"email notification: {new_count} new email{plural}") + msg = f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view | close with: drone @ai_mail close " + logger.info("[HOOKS] email: %d new email%s", new_count, plural) + return {"stdout": msg, "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/notification/stop_sound.py b/src/aipass/hooks/apps/handlers/notification/stop_sound.py new file mode 100644 index 00000000..4bb3f75c --- /dev/null +++ b/src/aipass/hooks/apps/handlers/notification/stop_sound.py @@ -0,0 +1,87 @@ +# =================== AIPass ==================== +# Name: stop_sound.py +# Version: 1.1.0 +# Description: Plays achievement bell + Piper voice on Stop events +# Branch: hooks +# Layer: apps/handlers/notification +# Created: 2026-05-20 +# Modified: 2026-05-20 +# ============================================= + +"""Plays achievement bell when the AI finishes responding (Stop event).""" + +import os +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", "")) +SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds" +SOUND_FILE = SOUNDS_DIR / "mixkit-achievement-bell-600.wav" + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _play(sound_path: Path) -> None: + """Play a WAV file via aplay (fire-and-forget).""" + if not sound_path.exists(): + logger.info("[HOOKS] stop_sound: file not found: %s", sound_path) + return + try: + subprocess.Popen( + ["aplay", "-q", str(sound_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError as exc: + logger.info("[HOOKS] stop_sound: playback error: %s", exc) + + +def _speak(text: str) -> None: + """Generate speech via Piper TTS and play it (fire-and-forget).""" + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + logger.info("[HOOKS] stop_sound: piper not available") + return + + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + + piper_result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + + if piper_result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen( + ["aplay", "-q", wav_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.TimeoutExpired: + logger.info("[HOOKS] stop_sound: piper timed out") + except OSError as exc: + logger.info("[HOOKS] stop_sound: speak error: %s", exc) + + +def handle(hook_data: dict) -> dict: + """Play achievement bell and speak hook name on Stop event. + + Args: + hook_data: Parsed hook event dict from engine. + + Returns: + Result dict with stdout (empty) and exit_code. + """ + if hook_data.get("stop_hook_active", False): + return {"stdout": "", "exit_code": 0} + + _speak("stop sound") + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/prompt/branch_loader.py b/src/aipass/hooks/apps/handlers/prompt/branch_loader.py new file mode 100644 index 00000000..485ff4e2 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/prompt/branch_loader.py @@ -0,0 +1,85 @@ +# =================== AIPass ==================== +# Name: branch_loader.py +# Version: 1.0.0 +# Description: Loads branch-specific prompt + private integrations (UserPromptSubmit) +# Branch: hooks +# Layer: apps/handlers/prompt +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Loads .aipass/aipass_local_prompt.md and private integration prompts for injection.""" + +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.info("[HOOKS] branch_loader: speak error: %s", exc) + + +def _find_branch_root(cwd: str) -> Path | None: + """Walk up from CWD looking for .trinity/ or apps/ — stop at repo root.""" + search = Path(cwd).resolve() + while search.parent != search: + if (search / ".trinity").is_dir() or (search / "apps").is_dir(): + return search + if (search / "pyproject.toml").exists() or (search / ".git").is_dir(): + return None + search = search.parent + return None + + +def handle(hook_data: dict) -> dict: + """Load branch prompt and private integration prompts.""" + _speak("branch prompt") + + try: + cwd = hook_data.get("cwd", "") or str(Path.cwd()) + branch_root = _find_branch_root(cwd) + if not branch_root: + return {"stdout": "", "exit_code": 0} + + parts: list[str] = [] + + prompt_file = branch_root / ".aipass" / "aipass_local_prompt.md" + if prompt_file.exists(): + content = prompt_file.read_text(encoding="utf-8").strip() + branch_name = branch_root.name.upper() + parts.append(f"# Branch Context: {branch_name}\n\n{content}") + + integrations_dir = branch_root / "apps" / "integrations" + if integrations_dir.is_dir(): + for prompt in sorted(integrations_dir.glob("*/private_prompt.md")): + parts.append(prompt.read_text(encoding="utf-8").strip()) + + if not parts: + return {"stdout": "", "exit_code": 0} + + return {"stdout": "\n".join(parts), "exit_code": 0} + + except Exception as exc: + logger.info("[HOOKS] branch_loader: unexpected error: %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/prompt/global_loader.py b/src/aipass/hooks/apps/handlers/prompt/global_loader.py new file mode 100644 index 00000000..3c2e98a8 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/prompt/global_loader.py @@ -0,0 +1,62 @@ +# =================== AIPass ==================== +# Name: global_loader.py +# Version: 1.0.0 +# Description: Loads AIPass global prompt for injection (UserPromptSubmit) +# Branch: hooks +# Layer: apps/handlers/prompt +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Loads .aipass/aipass_global_prompt.md from AIPASS_HOME for prompt injection.""" + +import os +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.info("[HOOKS] global_loader: speak error: %s", exc) + + +def handle(hook_data: dict) -> dict: + """Load AIPass global prompt from AIPASS_HOME.""" + _speak("global prompt") + + try: + aipass_home = os.environ.get("AIPASS_HOME", "") + if not aipass_home: + return {"stdout": "", "exit_code": 0} + + prompt_file = Path(aipass_home) / ".aipass" / "aipass_global_prompt.md" + if not prompt_file.exists(): + return {"stdout": "", "exit_code": 0} + + content = prompt_file.read_text(encoding="utf-8") + return {"stdout": content, "exit_code": 0} + + except Exception as exc: + logger.info("[HOOKS] global_loader: unexpected error: %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/prompt/identity.py b/src/aipass/hooks/apps/handlers/prompt/identity.py new file mode 100644 index 00000000..1e4eceb9 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/prompt/identity.py @@ -0,0 +1,113 @@ +# =================== AIPass ==================== +# Name: identity.py +# Version: 1.0.0 +# Description: Injects branch identity from passport.json (UserPromptSubmit) +# Branch: hooks +# Layer: apps/handlers/prompt +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Reads .trinity/passport.json and outputs formatted identity for prompt injection.""" + +import json +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.info("[HOOKS] identity: speak error: %s", exc) + + +def _find_passport(cwd: str) -> Path | None: + """Walk up from CWD looking for .trinity/passport.json.""" + search = Path(cwd).resolve() + home = Path.home() + while search != home and search.parent != search: + passport = search / ".trinity" / "passport.json" + if passport.exists(): + return passport + search = search.parent + return None + + +def _format_identity(data: dict) -> str: + lines: list[str] = [] + + branch = data.get("branch_info", {}) + identity = data.get("identity", {}) + name = branch.get("branch_name") or identity.get("name", "UNKNOWN") + lines.append(f"# {name} Identity") + lines.append(f"Path: {branch.get('path', 'unknown')}") + lines.append(f"Email: {branch.get('email', 'unknown')}") + + if identity.get("role"): + lines.append(f"Role: {identity['role']}") + + traits = identity.get("traits") or data.get("traits") + if traits: + if isinstance(traits, list): + lines.append("Traits: " + " | ".join(traits)) + else: + lines.append(f"Traits: {traits}") + + if identity.get("purpose"): + lines.append(f"Purpose: {identity['purpose']}") + + what_i_do = identity.get("what_i_do", []) + if what_i_do: + lines.append("Do: " + " | ".join(what_i_do[:4])) + + what_i_dont_do = identity.get("what_i_dont_do", []) + if what_i_dont_do: + lines.append("Don't: " + " | ".join(what_i_dont_do[:3])) + + principles = data.get("principles", []) + if principles: + lines.append("Principles: " + " * ".join(principles)) + + return "\n".join(lines) + + +def handle(hook_data: dict) -> dict: + """Inject branch identity from passport.json into prompt context.""" + _speak("identity") + + try: + cwd = hook_data.get("cwd", "") or str(Path.cwd()) + passport = _find_passport(cwd) + if not passport: + return {"stdout": "", "exit_code": 0} + + data = json.loads(passport.read_text(encoding="utf-8")) + output = _format_identity(data) + if not output: + return {"stdout": "", "exit_code": 0} + + return {"stdout": f"\n{output}", "exit_code": 0} + + except Exception as exc: + logger.info("[HOOKS] identity: unexpected error: %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/security/edit_gate.py b/src/aipass/hooks/apps/handlers/security/edit_gate.py new file mode 100644 index 00000000..e2aaf64e --- /dev/null +++ b/src/aipass/hooks/apps/handlers/security/edit_gate.py @@ -0,0 +1,160 @@ +# =================== AIPass ==================== +# Name: edit_gate.py +# Version: 1.0.0 +# Description: Cross-branch and inbox write protection (PreToolUse) +# Branch: hooks +# Layer: apps/handlers/security +# Created: 2026-05-21 +# Modified: 2026-05-21 +# ============================================= + +"""Blocks unsafe edits: inbox writes, daemon confinement, cross-branch writes, diagnostics state.""" + +import json +import os +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.info("[HOOKS] edit_gate: speak error: %s", exc) + + +STATE_FILE = Path(__file__).parent.parent.parent.parent.parent / ".diagnostics_state.json" +EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} +TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn") + + +def _get_branch(file_path: str) -> str: + parts = Path(file_path).parts + for i, part in enumerate(parts): + if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts): + return parts[i + 1] + return "" + + +def handle(hook_data: dict) -> dict: + """Apply edit security gates and return block or allow decision. + + Args: + hook_data: Parsed hook event dict from engine. + + Returns: + Result dict with stdout (block JSON or empty) and exit_code. + """ + _speak("edit gate") + + try: + tool_name = hook_data.get("tool_name", "") + tool_input = hook_data.get("tool_input", {}) + file_path = tool_input.get("file_path", "") + + if tool_name not in EDIT_TOOLS: + return {"stdout": "", "exit_code": 0} + + if not file_path: + return {"stdout": "", "exit_code": 0} + + fp = Path(file_path) + if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts: + reason = 'Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @ "Subject" "Body"' + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + + cwd = hook_data.get("cwd", "") or os.getcwd() + cwd_branch = _get_branch(cwd) + + session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive") + if session_type == "daemon" and cwd_branch: + target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp)) + if target_branch and target_branch != cwd_branch: + reason = ( + f"Dispatched agent confined to own branch: '{cwd_branch}' " + f"cannot write to '{target_branch}' in daemon mode." + ) + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + repo_root = None + for parent in Path(cwd).parents: + if (parent / ".git").exists(): + repo_root = parent + break + if repo_root and not target_branch: + allowed_prefix = str(repo_root / "src" / "aipass" / cwd_branch) + resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp) + if not resolved.startswith(allowed_prefix): + reason = f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}" + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + + target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp)) + + if cwd_branch and target_branch and cwd_branch != target_branch: + if cwd_branch not in TRUSTED_CROSS_WRITERS: + reason = ( + f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n" + f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}" + ) + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + + if not file_path.endswith(".py"): + return {"stdout": "", "exit_code": 0} + + if not STATE_FILE.exists(): + return {"stdout": "", "exit_code": 0} + + try: + state = json.loads(STATE_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, IOError) as exc: + logger.info("[HOOKS] edit_gate: diagnostics_state unreadable: %s", exc) + return {"stdout": "", "exit_code": 0} + + errored_file = state.get("file", "") + errors = state.get("errors", []) + + if not errors: + return {"stdout": "", "exit_code": 0} + + try: + current = str(Path(file_path).resolve()) + errored = str(Path(errored_file).resolve()) + except (OSError, ValueError) as exc: + logger.info("[HOOKS] edit_gate: path resolution failed: %s", exc) + return {"stdout": "", "exit_code": 0} + + if current == errored: + return {"stdout": "", "exit_code": 0} + + current_branch = _get_branch(current) + errored_branch = _get_branch(errored) + if not errored_branch: + return {"stdout": "", "exit_code": 0} + if current_branch and errored_branch and current_branch != errored_branch: + return {"stdout": "", "exit_code": 0} + + error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5]) + reason = f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}" + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + + except Exception as exc: + logger.info("[HOOKS] edit_gate: unexpected error (allowing): %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/security/git_gate.py b/src/aipass/hooks/apps/handlers/security/git_gate.py new file mode 100644 index 00000000..5263823d --- /dev/null +++ b/src/aipass/hooks/apps/handlers/security/git_gate.py @@ -0,0 +1,148 @@ +# =================== AIPass ==================== +# Name: git_gate.py +# Version: 1.0.0 +# Description: Blocks raw git/gh commands and protected file edits (PreToolUse) +# Branch: hooks +# Layer: apps/handlers/security +# Created: 2026-05-21 +# Modified: 2026-05-21 +# ============================================= + +"""Blocks raw git/gh commands and edits to settings/hooks files.""" + +import json +import os +import re +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.info("[HOOKS] git_gate: speak error: %s", exc) + + +RAW_GIT_RE = re.compile(r"(? str: + parts = Path(cwd).parts + for i, part in enumerate(parts): + if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts): + return parts[i + 1] + return "" + + +def _is_allowed_gh(cmd: str) -> bool: + match = re.search(r"(? dict: + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + + +def _check_bash(tool_input: dict) -> dict: + cmd = tool_input.get("command", "") + if not cmd: + return _BLOCK_ALLOW + scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd) + scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan) + if RAW_GIT_RE.search(scan): + return _block(GIT_GH_REDIRECT) + if RAW_GH_RE.search(scan) and not _is_allowed_gh(cmd): + return _block(GIT_GH_REDIRECT) + return _BLOCK_ALLOW + + +def _check_edit(tool_input: dict, cwd: str) -> dict: + file_path = tool_input.get("file_path") or tool_input.get("notebook_path") or "" + if not file_path: + return _BLOCK_ALLOW + for pat in BLOCKED_EDIT_PATTERNS: + if pat.search(file_path): + if _cwd_branch(cwd) in TRUSTED_HOOK_EDITORS: + return _BLOCK_ALLOW + return _block(EDIT_REDIRECT.format(path=file_path)) + return _BLOCK_ALLOW + + +def handle(hook_data: dict) -> dict: + """Block raw git/gh commands and protected file edits. + + Args: + hook_data: Parsed hook event dict from engine. + + Returns: + Result dict with stdout (block JSON or empty) and exit_code. + """ + _speak("git gate") + + try: + tool_name = hook_data.get("tool_name", "") + tool_input = hook_data.get("tool_input", {}) + cwd = hook_data.get("cwd", "") or os.getcwd() + if tool_name == "Bash": + return _check_bash(tool_input) + if tool_name in EDIT_TOOLS: + return _check_edit(tool_input, cwd) + return _BLOCK_ALLOW + except Exception as exc: + logger.info("[HOOKS] git_gate: unexpected error (allowing): %s", exc) + return _BLOCK_ALLOW diff --git a/src/aipass/hooks/apps/handlers/security/subagent_gate.py b/src/aipass/hooks/apps/handlers/security/subagent_gate.py new file mode 100644 index 00000000..d4113bc7 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/security/subagent_gate.py @@ -0,0 +1,203 @@ +# =================== AIPass ==================== +# Name: subagent_gate.py +# Version: 1.0.0 +# Description: Checks modified Python files against seedgo standards on SubagentStop +# Branch: hooks +# Layer: apps/handlers/security +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Checks modified Python files against seedgo standards and blocks on violations.""" + +import json +import os +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + +_ALLOW = {"stdout": "", "exit_code": 0} + + +def _speak(text: str) -> None: + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, mode="wb") + wav_path = wav_file.name + wav_file.close() + result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.info("[HOOKS] subagent_gate: speak error: %s", exc) + + +def _block(reason: str) -> dict: + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + + +def _find_repo_root(cwd: str) -> Path | None: + """Walk up from AIPASS_HOME or CWD to find the git repo root.""" + for start in (os.environ.get("AIPASS_HOME", ""), cwd): + if not start: + continue + p = Path(start) + while p != p.parent: + if (p / ".git").exists(): + return p + p = p.parent + return None + + +def _get_cwd_branch(cwd: str, repo_root: Path) -> str | None: + """Detect which branch directory (src/aipass/) the CWD is in.""" + src = repo_root / "src" / "aipass" + try: + rel = Path(cwd).resolve().relative_to(src) + return rel.parts[0] if rel.parts else None + except ValueError: + logger.info("[HOOKS] subagent_gate: CWD %s not inside src/aipass", cwd) + return None + + +def _get_modified_py_files(cwd: str, repo_root: Path) -> list[str]: + """Get modified .py files scoped to the CWD branch via drone.""" + cwd_branch = _get_cwd_branch(cwd, repo_root) + branch_dir = repo_root / "src" / "aipass" / cwd_branch if cwd_branch else None + if not branch_dir or not branch_dir.exists(): + return [] + result = subprocess.run( + ["drone", "@git", "status"], + capture_output=True, + text=True, + timeout=10, + cwd=str(branch_dir), + ) + files: list[str] = [] + for line in result.stdout.strip().split("\n"): + line = line.strip() + if not line or "file(s) changed" in line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + _, filepath = parts + if not filepath.endswith(".py") or filepath.startswith(".claude/"): + continue + full = repo_root / filepath + if full.exists(): + files.append(str(full)) + return files + + +def _run_seedgo_checklist(file_path: str, repo_root: Path) -> list[str]: + """Run seedgo checklist on a single file, return violation strings.""" + if "/.claude/" in file_path: + return [] + result = subprocess.run( + ["drone", "@seedgo", "checklist", file_path], + capture_output=True, + text=True, + timeout=15, + cwd=str(repo_root), + ) + if result.returncode != 0: + return [] + violations: list[str] = [] + for line in result.stdout.split("\n"): + line = line.strip() + if line.startswith("✗"): + v = line[1:].strip() + if v: + violations.append(v) + return violations[:5] + + +def _check_hook_readme_accountability(cwd: str, repo_root: Path) -> str | None: + """Return advisory if hook files changed without README update.""" + cwd_branch = _get_cwd_branch(cwd, repo_root) + branch_dir = repo_root / "src" / "aipass" / cwd_branch if cwd_branch else None + if not branch_dir or not branch_dir.exists(): + return None + result = subprocess.run( + ["drone", "@git", "status", "--all"], + capture_output=True, + text=True, + timeout=10, + cwd=str(branch_dir), + ) + changed: list[str] = [] + for line in result.stdout.strip().split("\n"): + line = line.strip() + if not line or "file(s) changed" in line: + continue + parts = line.split(None, 1) + if len(parts) == 2: + changed.append(parts[1]) + + hook_files_changed = any(f.startswith(".claude/hooks/") and f.endswith(".py") for f in changed) + readme_changed = ".claude/hooks/README.md" in changed + + if hook_files_changed and not readme_changed: + return ( + "Hook files were modified but .claude/hooks/README.md was not updated. " + "Consider updating the README to reflect your changes." + ) + return None + + +def handle(hook_data: dict) -> dict: + """Check modified files against seedgo standards on subagent stop.""" + _speak("subagent stop gate") + + try: + cwd = hook_data.get("cwd", "") or os.getcwd() + repo_root = _find_repo_root(cwd) + if repo_root is None: + return _ALLOW + + modified = _get_modified_py_files(cwd, repo_root) + if not modified: + return _ALLOW + + readme_reminder = _check_hook_readme_accountability(cwd, repo_root) + + all_violations: dict[str, list[str]] = {} + for f in modified: + vs = _run_seedgo_checklist(f, repo_root) + if vs: + name = Path(f).name + all_violations[name] = vs + + if all_violations: + 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.") + if readme_reminder: + lines.append(f"\n{readme_reminder}") + return _block("\n".join(lines)) + + if readme_reminder: + result_data = {"decision": "allow", "reason": readme_reminder} + return {"stdout": json.dumps(result_data), "exit_code": 0} + + return _ALLOW + + except Exception as exc: + logger.info("[HOOKS] subagent_gate: unexpected error (allowing): %s", exc) + return _ALLOW diff --git a/src/aipass/hooks/tests/test_announce.py b/src/aipass/hooks/tests/test_announce.py new file mode 100644 index 00000000..ae739358 --- /dev/null +++ b/src/aipass/hooks/tests/test_announce.py @@ -0,0 +1,168 @@ +# =================== AIPass ==================== +# Name: test_announce.py +# Version: 1.1.0 +# Description: Tests for announce notification handler +# Branch: hooks +# Created: 2026-05-20 +# Modified: 2026-05-20 +# ============================================= + +"""Tests for handlers/notification/announce.py.""" + +from unittest.mock import patch, MagicMock + + +class TestAnnounceHandler: + """Core handler behavior tests.""" + + def test_handle_returns_result_dict(self): + from aipass.hooks.apps.handlers.notification.announce import handle + + with ( + patch("aipass.hooks.apps.handlers.notification.announce._play"), + patch("aipass.hooks.apps.handlers.notification.announce._speak"), + ): + result = handle({}) + + assert isinstance(result, dict) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_handle_speaks_notification_sound(self): + from aipass.hooks.apps.handlers.notification.announce import handle + + with ( + patch("aipass.hooks.apps.handlers.notification.announce._play"), + patch("aipass.hooks.apps.handlers.notification.announce._speak") as mock_speak, + ): + handle({}) + + mock_speak.assert_called_once_with("notification sound") + + def test_handle_does_not_play_wav(self): + from aipass.hooks.apps.handlers.notification.announce import handle + + with ( + patch("aipass.hooks.apps.handlers.notification.announce._play") as mock_play, + patch("aipass.hooks.apps.handlers.notification.announce._speak"), + ): + handle({}) + + mock_play.assert_not_called() + + +class TestPlayFunction: + """WAV playback tests.""" + + def test_play_calls_aplay(self): + from aipass.hooks.apps.handlers.notification.announce import _play + + mock_path = MagicMock() + mock_path.exists.return_value = True + + with patch("aipass.hooks.apps.handlers.notification.announce.subprocess.Popen") as mock_popen: + _play(mock_path) + + mock_popen.assert_called_once() + args = mock_popen.call_args[0][0] + assert args[0] == "aplay" + assert args[1] == "-q" + + def test_play_skips_when_file_missing(self): + from aipass.hooks.apps.handlers.notification.announce import _play + + mock_path = MagicMock() + mock_path.exists.return_value = False + + with patch("aipass.hooks.apps.handlers.notification.announce.subprocess.Popen") as mock_popen: + _play(mock_path) + + mock_popen.assert_not_called() + + def test_play_graceful_on_os_error(self): + from aipass.hooks.apps.handlers.notification.announce import _play + + mock_path = MagicMock() + mock_path.exists.return_value = True + + with patch( + "aipass.hooks.apps.handlers.notification.announce.subprocess.Popen", + side_effect=OSError("broken"), + ): + _play(mock_path) + + +class TestSpeakFunction: + """Piper TTS tests.""" + + def test_speak_calls_piper_then_aplay(self): + from aipass.hooks.apps.handlers.notification.announce import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.announce.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.announce.subprocess") as mock_sub, + patch("aipass.hooks.apps.handlers.notification.announce.tempfile") as mock_tmp, + patch("aipass.hooks.apps.handlers.notification.announce.Path") as mock_path, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + mock_sub.run.return_value = MagicMock(returncode=0) + mock_path.return_value.exists.return_value = True + + _speak("test text") + + mock_sub.run.assert_called_once() + mock_sub.Popen.assert_called_once() + + def test_speak_skips_when_piper_missing(self): + from aipass.hooks.apps.handlers.notification.announce import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.announce.subprocess") as mock_sub, + ): + mock_piper_bin.exists.return_value = False + _speak("test") + + mock_sub.run.assert_not_called() + + def test_speak_graceful_on_timeout(self): + import subprocess as real_sub + from aipass.hooks.apps.handlers.notification.announce import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.announce.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.announce.subprocess") as mock_sub, + patch("aipass.hooks.apps.handlers.notification.announce.tempfile") as mock_tmp, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5) + mock_sub.TimeoutExpired = real_sub.TimeoutExpired + + _speak("test") + + def test_speak_graceful_on_os_error(self): + from aipass.hooks.apps.handlers.notification.announce import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.announce.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.announce.subprocess.run", side_effect=OSError("broken")), + patch("aipass.hooks.apps.handlers.notification.announce.tempfile") as mock_tmp, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + + _speak("test") diff --git a/src/aipass/hooks/tests/test_auto_fix.py b/src/aipass/hooks/tests/test_auto_fix.py new file mode 100644 index 00000000..aa59be21 --- /dev/null +++ b/src/aipass/hooks/tests/test_auto_fix.py @@ -0,0 +1,427 @@ +# =================== AIPass ==================== +# Name: test_auto_fix.py +# Version: 1.0.0 +# Description: Tests for auto_fix lifecycle handler +# Branch: hooks +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Tests for handlers/lifecycle/auto_fix.py.""" + +import json +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + + +class TestAutoFixSkips: + def test_skip_non_edit_tool(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + result = handle({"tool_name": "Bash", "tool_input": {"command": "ls"}}) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_skip_non_code_file_md(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/README.md"}}) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_skip_non_code_file_txt(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + result = handle({"tool_name": "Write", "tool_input": {"file_path": "/tmp/notes.txt"}}) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_skip_non_code_file_html(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/page.html"}}) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_empty_hook_data(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + result = handle({}) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_missing_file_path(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + result = handle({"tool_name": "Edit", "tool_input": {}}) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_skip_unknown_extension(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak"): + result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/file.xyz"}}) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + +class TestAutofixPython: + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) + def test_python_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}}) + assert result["exit_code"] == 0 + parsed = json.loads(result["stdout"]) + assert parsed["systemMessage"] == "[diagnostics] ok" + + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks") + def test_python_syntax_error(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + mock_py.return_value = ["SYNTAX: invalid syntax at line 5"] + result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/bad.py"}}) + assert result["exit_code"] == 0 + parsed = json.loads(result["stdout"]) + assert "additionalContext" in parsed.get("hookSpecificOutput", {}) + assert "SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"] + assert "1 error(s)" in parsed["systemMessage"] + + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks") + def test_python_ruff_lint_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + mock_py.return_value = ["LINT: bad.py:10:1: F401 unused import"] + result = handle({"tool_name": "Write", "tool_input": {"file_path": "/tmp/bad.py"}}) + parsed = json.loads(result["stdout"]) + assert "LINT" in parsed["hookSpecificOutput"]["additionalContext"] + + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) + def test_python_pyright_errors(self, mock_py, mock_ruff_s, mock_seedgo, mock_speak): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + with patch( + "aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", + return_value=[{"line": 42, "message": "Cannot assign to declared type"}], + ): + result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/typed.py"}}) + + parsed = json.loads(result["stdout"]) + assert "TYPE: L42" in parsed["hookSpecificOutput"]["additionalContext"] + + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) + def test_seedgo_violations_surfaced(self, mock_py, mock_ruff_s, mock_pyright, mock_speak): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + with patch( + "aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", + return_value=["missing file header"], + ): + result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/noheader.py"}}) + + parsed = json.loads(result["stdout"]) + assert "SEEDGO: missing file header" in parsed["hookSpecificOutput"]["additionalContext"] + + +class TestAutoFixStateFile: + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) + def test_state_file_written_on_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + mock_ruff_s.return_value = [{"line": 5, "message": "F401: unused import"}] + mock_pyright.return_value = [{"line": 10, "message": "Type error here"}] + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf: + state_path = Path(tf.name) + + try: + with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.STATE_FILE", state_path): + handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/errors.py"}}) + + assert state_path.exists() + state = json.loads(state_path.read_text(encoding="utf-8")) + assert len(state["errors"]) == 2 + assert state["errors"][0]["line"] == 5 + assert state["errors"][1]["line"] == 10 + finally: + if state_path.exists(): + state_path.unlink() + + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) + def test_state_file_cleared_on_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as tf: + state_path = Path(tf.name) + tf.write('{"file": "/tmp/old.py", "errors": [{"line": 1, "message": "old"}]}') + + try: + with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.STATE_FILE", state_path): + handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}}) + + assert not state_path.exists() + finally: + if state_path.exists(): + state_path.unlink() + + +class TestAutoFixJson: + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + def test_json_valid(self, mock_speak, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + json_file = tmp_path / "good.json" + json_file.write_text('{"key": "value"}', encoding="utf-8") + + result = handle({"tool_name": "Edit", "tool_input": {"file_path": str(json_file)}}) + parsed = json.loads(result["stdout"]) + assert parsed["systemMessage"] == "[diagnostics] ok" + + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + def test_json_invalid_syntax(self, mock_speak, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + json_file = tmp_path / "bad.json" + json_file.write_text('{"key": }', encoding="utf-8") + + result = handle({"tool_name": "Write", "tool_input": {"file_path": str(json_file)}}) + parsed = json.loads(result["stdout"]) + assert "JSON SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"] + + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + def test_json_corruption_detected(self, mock_speak, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + json_file = tmp_path / "corrupt.json" + json_file.write_text('{"data": "\x00bad"}', encoding="utf-8") + + result = handle({"tool_name": "Edit", "tool_input": {"file_path": str(json_file)}}) + parsed = json.loads(result["stdout"]) + assert "EMOJI CORRUPTION" in parsed["hookSpecificOutput"]["additionalContext"] + + +class TestAutoFixPiper: + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.PIPER_VOICE") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.PIPER_BIN") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) + @patch("subprocess.run") + @patch("subprocess.Popen") + def test_piper_fires_on_edit( + self, + mock_popen, + mock_run, + mock_py, + mock_ruff_s, + mock_pyright, + mock_seedgo, + mock_piper_bin, + mock_piper_voice, + ): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle + + mock_piper_bin.exists.return_value = True + mock_piper_voice.exists.return_value = True + mock_run_result = MagicMock() + mock_run_result.returncode = 0 + mock_run.return_value = mock_run_result + + with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.Path") as mock_path_cls: + mock_path_cls.return_value.suffix.lower.return_value = ".py" + mock_path_cls.return_value.name = "test.py" + mock_path_cls.return_value.exists.return_value = True + + handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/test.py"}}) + + assert mock_run.called + + def test_piper_skips_when_unavailable(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _speak + + with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.PIPER_BIN") as mock_bin: + mock_bin.exists.return_value = False + _speak("test") + + +class TestAutoFixSubprocessChecks: + @patch("subprocess.run") + def test_check_syntax_error(self, mock_run): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_syntax + + mock_run.return_value = MagicMock(returncode=1, stderr="SyntaxError: invalid syntax") + errors = _check_syntax("/tmp/bad.py") + assert len(errors) == 1 + assert "SYNTAX" in errors[0] + + @patch("subprocess.run") + def test_check_syntax_clean(self, mock_run): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_syntax + + mock_run.return_value = MagicMock(returncode=0, stderr="") + errors = _check_syntax("/tmp/good.py") + assert errors == [] + + @patch("subprocess.run") + def test_check_ruff_lint_findings(self, mock_run): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_ruff_lint + + mock_run.return_value = MagicMock(returncode=1, stdout="bad.py:10:1: F401 unused import\n") + errors = _check_ruff_lint("/tmp/bad.py") + assert len(errors) == 1 + assert "LINT" in errors[0] + + @patch("subprocess.run") + def test_check_ruff_format_drift(self, mock_run): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_ruff_format + + mock_run.return_value = MagicMock(returncode=1) + errors = _check_ruff_format("/tmp/unformatted.py") + assert len(errors) == 1 + assert "FORMAT" in errors[0] + + @patch("subprocess.run") + def test_run_ruff_lint_structured_returns_dicts(self, mock_run): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_ruff_lint_structured + + mock_run.return_value = MagicMock( + returncode=1, + stdout=json.dumps( + [ + {"location": {"row": 5}, "code": "F401", "message": "unused import os"}, + ] + ), + ) + errors = _run_ruff_lint_structured("/tmp/lint.py") + assert len(errors) == 1 + assert errors[0]["line"] == 5 + assert "F401" in errors[0]["message"] + + @patch("subprocess.run") + def test_run_ruff_lint_structured_skips_claude_hooks(self, mock_run): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_ruff_lint_structured + + errors = _run_ruff_lint_structured("/home/user/.claude/hooks/myhook.py") + assert errors == [] + mock_run.assert_not_called() + + @patch("subprocess.run") + def test_run_pyright_check_returns_errors(self, mock_run): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_pyright_check + + mock_run.return_value = MagicMock( + returncode=1, + stdout=json.dumps( + { + "generalDiagnostics": [ + { + "severity": "error", + "range": {"start": {"line": 42}}, + "message": "Cannot assign type", + }, + { + "severity": "warning", + "range": {"start": {"line": 10}}, + "message": "This is a warning", + }, + ], + } + ), + ) + errors = _run_pyright_check("/tmp/typed.py") + assert len(errors) == 1 + assert errors[0]["line"] == 42 + + @patch("subprocess.run") + def test_run_pyright_skips_claude_hooks(self, mock_run): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_pyright_check + + errors = _run_pyright_check("/home/user/.claude/hooks/myhook.py") + assert errors == [] + mock_run.assert_not_called() + + @patch("subprocess.run") + def test_run_seedgo_checklist_returns_violations(self, mock_run): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_seedgo_checklist + + mock_run.return_value = MagicMock( + returncode=0, + stdout="✓ file_header: OK\n✗ missing encoding param\n✗ bad import\n", + ) + with patch.dict("os.environ", {"AIPASS_HOME": "/home/user/Projects/AIPass"}): + violations = _run_seedgo_checklist("/tmp/check.py") + assert len(violations) == 2 + assert "missing encoding param" in violations[0] + + @patch("subprocess.run") + def test_run_seedgo_skips_claude_hooks(self, mock_run): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_seedgo_checklist + + violations = _run_seedgo_checklist("/home/user/.claude/hooks/myhook.py") + assert violations == [] + mock_run.assert_not_called() + + def test_run_seedgo_skips_without_aipass_home(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_seedgo_checklist + + with patch.dict("os.environ", {}, clear=True): + violations = _run_seedgo_checklist("/tmp/check.py") + assert violations == [] + + +class TestAutoFixPatterns: + def test_check_line_pattern_matches(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_line_pattern + + assert _check_line_pattern(" logger.debug(msg)", "logger.debug(") is True + + def test_check_line_pattern_skips_comments(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_line_pattern + + assert _check_line_pattern(" # logger.debug(msg)", "logger.debug(") is False + + def test_check_line_pattern_skips_strings(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_line_pattern + + assert _check_line_pattern(' msg = "logger.debug(test)"', "logger.debug(") is False + + def test_check_emoji_list_clean(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_emoji_list + + assert _check_emoji_list(["hello", "world"], "emojis") is None + + def test_check_emoji_list_suspicious(self): + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_emoji_list + + result = _check_emoji_list(["a"], "emojis") + assert result is not None + assert "EMOJI CORRUPTION" in result diff --git a/src/aipass/hooks/tests/test_auto_watchdog.py b/src/aipass/hooks/tests/test_auto_watchdog.py new file mode 100644 index 00000000..79fed2e3 --- /dev/null +++ b/src/aipass/hooks/tests/test_auto_watchdog.py @@ -0,0 +1,87 @@ +# =================== AIPass ==================== +# Name: test_auto_watchdog.py +# Version: 1.0.0 +# Description: Tests for auto_watchdog lifecycle handler +# Branch: hooks +# Created: 2026-05-21 +# Modified: 2026-05-21 +# ============================================= + +"""Tests for handlers/lifecycle/auto_watchdog.py.""" + +import json + + +class TestAutoWatchdogHandler: + def test_handle_returns_result_dict(self): + from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle + + result = handle({"tool_name": "Bash", "tool_input": {"command": "ls"}}) + assert isinstance(result, dict) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_dispatch_detected(self): + from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": 'drone @ai_mail dispatch @hooks "Subject" "Body"'}, + } + ) + assert result["exit_code"] == 0 + parsed = json.loads(result["stdout"]) + assert "additionalContext" in parsed + assert "AUTO-WATCHDOG" in parsed["additionalContext"] + + def test_skip_non_bash(self): + from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle + + result = handle( + { + "tool_name": "Edit", + "tool_input": {"command": "drone @ai_mail dispatch @hooks"}, + } + ) + assert result["stdout"] == "" + + def test_skip_when_watchdog_in_command(self): + from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": "drone @ai_mail dispatch @hooks && while [ unread_count ]; do sleep 1; done"}, + } + ) + assert result["stdout"] == "" + + def test_skip_dispatch_wake_without_target(self): + from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": "drone @ai_mail dispatch wake"}, + } + ) + assert result["stdout"] == "" + + def test_normal_bash_no_dispatch(self): + from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": "cd /tmp && ls -la"}, + } + ) + assert result["stdout"] == "" + + def test_empty_hook_data(self): + from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle + + result = handle({}) + assert result["stdout"] == "" + assert result["exit_code"] == 0 diff --git a/src/aipass/hooks/tests/test_branch_loader.py b/src/aipass/hooks/tests/test_branch_loader.py new file mode 100644 index 00000000..b48c1ccf --- /dev/null +++ b/src/aipass/hooks/tests/test_branch_loader.py @@ -0,0 +1,136 @@ +# =================== AIPass ==================== +# Name: test_branch_loader.py +# Version: 1.0.0 +# Description: Tests for branch_loader prompt handler +# Branch: hooks +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Tests for handlers/prompt/branch_loader.py.""" + +from pathlib import Path +from unittest.mock import patch + + +class TestBranchLoaderHandler: + def test_loads_branch_prompt(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.branch_loader import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + prompt = aipass_dir / "aipass_local_prompt.md" + prompt.write_text("# Test Branch\nSome instructions", encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + result = handle({"cwd": str(tmp_path)}) + + assert result["exit_code"] == 0 + assert "Branch Context:" in result["stdout"] + assert "Some instructions" in result["stdout"] + + def test_loads_private_integrations(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.branch_loader import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + integration = tmp_path / "apps" / "integrations" / "test_int" + integration.mkdir(parents=True) + private = integration / "private_prompt.md" + private.write_text("# Private Integration\nSecret stuff", encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + result = handle({"cwd": str(tmp_path)}) + + assert "Private Integration" in result["stdout"] + + def test_loads_both_prompt_and_integrations(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.branch_loader import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "aipass_local_prompt.md").write_text("Branch prompt", encoding="utf-8") + integration = tmp_path / "apps" / "integrations" / "compass" + integration.mkdir(parents=True) + (integration / "private_prompt.md").write_text("Compass prompt", encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + result = handle({"cwd": str(tmp_path)}) + + assert "Branch prompt" in result["stdout"] + assert "Compass prompt" in result["stdout"] + + def test_returns_empty_when_no_branch_root(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.branch_loader import handle + + with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + result = handle({"cwd": str(tmp_path)}) + + assert result["stdout"] == "" + + def test_stops_at_repo_root(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.branch_loader import handle + + (tmp_path / ".git").mkdir() + nested = tmp_path / "some" / "deep" / "path" + nested.mkdir(parents=True) + + with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + result = handle({"cwd": str(nested)}) + + assert result["stdout"] == "" + + def test_walks_up_to_find_branch(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.branch_loader import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "aipass_local_prompt.md").write_text("Found it", encoding="utf-8") + nested = tmp_path / "apps" / "handlers" / "security" + nested.mkdir(parents=True) + + with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + result = handle({"cwd": str(nested)}) + + assert "Found it" in result["stdout"] + + def test_empty_hook_data(self): + from aipass.hooks.apps.handlers.prompt.branch_loader import handle + + with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_no_prompt_file_but_has_branch_root(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.branch_loader import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + + with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + result = handle({"cwd": str(tmp_path)}) + + assert result["stdout"] == "" + + def test_includes_source_path_in_output(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.branch_loader import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "aipass_local_prompt.md").write_text("content", encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + result = handle({"cwd": str(tmp_path)}) + + assert "Source:" in result["stdout"] diff --git a/src/aipass/hooks/tests/test_compact.py b/src/aipass/hooks/tests/test_compact.py new file mode 100644 index 00000000..bb595717 --- /dev/null +++ b/src/aipass/hooks/tests/test_compact.py @@ -0,0 +1,88 @@ +# =================== AIPass ==================== +# Name: test_compact.py +# Version: 1.0.0 +# Description: Tests for compact lifecycle handler +# Branch: hooks +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Tests for handlers/lifecycle/compact.py.""" + +import json +from unittest.mock import patch, MagicMock + + +class TestCompactHandler: + def test_injects_recovery_context(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.compact import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + local = trinity / "local.json" + local.write_text( + json.dumps( + { + "sessions": [{"id": "S10", "d": "2026-05-22", "sum": "did stuff"}], + "key_learnings": {"learn1": "value1"}, + } + ), + encoding="utf-8", + ) + status = tmp_path / "STATUS.local.md" + status.write_text("# Status\nCurrent work here", encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value="Git branch: dev"): + result = handle({"cwd": str(tmp_path)}) + + assert result["exit_code"] == 0 + assert "POST-COMPACT RECOVERY" in result["stdout"] + assert "Git branch: dev" in result["stdout"] + assert "did stuff" in result["stdout"] + assert "Current work here" in result["stdout"] + + def test_returns_recovery_when_no_branch_dir(self): + from aipass.hooks.apps.handlers.lifecycle.compact import handle + + with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): + result = handle({"cwd": "/tmp/nonexistent"}) + + assert result["exit_code"] == 0 + assert "POST-COMPACT RECOVERY" in result["stdout"] + + def test_dispatched_agent_gets_save_warning(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.compact import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + + with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): + with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "dispatched"}): + result = handle({"cwd": str(tmp_path)}) + + assert "SAVE STATE NOW" in result["stdout"] + + def test_interactive_gets_recovery_protocol(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.compact import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + + with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): + result = handle({"cwd": str(tmp_path)}) + + assert "Recovery Protocol" in result["stdout"] + + def test_empty_hook_data(self): + from aipass.hooks.apps.handlers.lifecycle.compact import handle + + with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): + with patch("pathlib.Path.cwd", return_value=MagicMock(parts=("/", "tmp"))): + result = handle({}) + + assert result["exit_code"] == 0 diff --git a/src/aipass/hooks/tests/test_edit_gate.py b/src/aipass/hooks/tests/test_edit_gate.py new file mode 100644 index 00000000..5baffbb4 --- /dev/null +++ b/src/aipass/hooks/tests/test_edit_gate.py @@ -0,0 +1,122 @@ +# =================== AIPass ==================== +# Name: test_edit_gate.py +# Version: 1.0.0 +# Description: Tests for edit_gate security handler +# Branch: hooks +# Created: 2026-05-21 +# Modified: 2026-05-21 +# ============================================= + +"""Tests for handlers/security/edit_gate.py.""" + +import json +from unittest.mock import patch + + +class TestEditGateHandler: + def test_allow_normal_edit(self): + from aipass.hooks.apps.handlers.security.edit_gate import handle + + result = handle( + { + "tool_name": "Edit", + "tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/apps/test.py"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/hooks", + } + ) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_block_inbox_write(self): + from aipass.hooks.apps.handlers.security.edit_gate import handle + + result = handle( + { + "tool_name": "Edit", + "tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/.ai_mail.local/inbox.json"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/hooks", + } + ) + assert result["exit_code"] == 2 + parsed = json.loads(result["stdout"]) + assert parsed["decision"] == "block" + assert "inbox.json" in parsed["reason"] + + def test_block_cross_branch(self): + from aipass.hooks.apps.handlers.security.edit_gate import handle + + result = handle( + { + "tool_name": "Edit", + "tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/apps/test.py"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 2 + parsed = json.loads(result["stdout"]) + assert parsed["decision"] == "block" + assert "Cross-branch" in parsed["reason"] + + def test_allow_trusted_cross_branch(self): + from aipass.hooks.apps.handlers.security.edit_gate import handle + + result = handle( + { + "tool_name": "Edit", + "tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/apps/test.py"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", + } + ) + assert result["exit_code"] == 0 + + def test_block_daemon_cross_branch(self): + from aipass.hooks.apps.handlers.security.edit_gate import handle + + with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "daemon"}): + result = handle( + { + "tool_name": "Edit", + "tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/apps/test.py"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 2 + parsed = json.loads(result["stdout"]) + assert "daemon" in parsed["reason"] + + def test_allow_daemon_own_branch(self): + from aipass.hooks.apps.handlers.security.edit_gate import handle + + with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "daemon"}): + result = handle( + { + "tool_name": "Edit", + "tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/api/apps/test.py"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 0 + + def test_skip_non_edit_tool(self): + from aipass.hooks.apps.handlers.security.edit_gate import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/.ai_mail.local/inbox.json"}, + } + ) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_empty_file_path(self): + from aipass.hooks.apps.handlers.security.edit_gate import handle + + result = handle({"tool_name": "Edit", "tool_input": {"file_path": ""}}) + assert result["exit_code"] == 0 + + def test_empty_hook_data(self): + from aipass.hooks.apps.handlers.security.edit_gate import handle + + result = handle({}) + assert result["exit_code"] == 0 diff --git a/src/aipass/hooks/tests/test_email.py b/src/aipass/hooks/tests/test_email.py new file mode 100644 index 00000000..ec0670db --- /dev/null +++ b/src/aipass/hooks/tests/test_email.py @@ -0,0 +1,380 @@ +# =================== AIPass ==================== +# Name: test_email.py +# Version: 1.1.0 +# Description: Tests for email notification handler +# Branch: hooks +# Created: 2026-05-21 +# Modified: 2026-05-21 +# ============================================= + +"""Tests for handlers/notification/email.py.""" + +import json +from pathlib import Path +from unittest.mock import patch, MagicMock + + +class TestEmailHandler: + """Core handler behavior tests.""" + + def test_handle_returns_result_dict(self): + from aipass.hooks.apps.handlers.notification.email import handle + + with patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=None, + ): + result = handle({}) + + assert isinstance(result, dict) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_handle_returns_notification_when_new_emails(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import handle + + inbox_dir = tmp_path / ".ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text( + json.dumps({"messages": [{"status": "new", "subject": "test"}]}), + encoding="utf-8", + ) + + with ( + patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=tmp_path, + ), + patch("aipass.hooks.apps.handlers.notification.email._speak"), + ): + result = handle({}) + + assert "1 new email" in result["stdout"] + assert "drone @ai_mail inbox" in result["stdout"] + assert result["exit_code"] == 0 + + def test_handle_speaks_when_new_emails(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import handle + + inbox_dir = tmp_path / ".ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text( + json.dumps({"messages": [{"status": "new", "subject": "test"}]}), + encoding="utf-8", + ) + + with ( + patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=tmp_path, + ), + patch("aipass.hooks.apps.handlers.notification.email._speak") as mock_speak, + ): + handle({}) + + mock_speak.assert_called_once_with("email notification: 1 new email") + + def test_handle_does_not_speak_when_no_emails(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import handle + + inbox_dir = tmp_path / ".ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text( + json.dumps({"messages": [{"status": "read"}]}), + encoding="utf-8", + ) + + with ( + patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=tmp_path, + ), + patch("aipass.hooks.apps.handlers.notification.email._speak") as mock_speak, + ): + handle({}) + + mock_speak.assert_not_called() + + def test_handle_returns_empty_when_no_new_emails(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import handle + + inbox_dir = tmp_path / ".ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text( + json.dumps({"messages": [{"status": "read", "subject": "old"}]}), + encoding="utf-8", + ) + + with ( + patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=tmp_path, + ), + patch("aipass.hooks.apps.handlers.notification.email._speak"), + ): + result = handle({}) + + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_handle_plural_for_multiple_emails(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import handle + + inbox_dir = tmp_path / ".ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text( + json.dumps( + { + "messages": [ + {"status": "new", "subject": "one"}, + {"status": "new", "subject": "two"}, + {"status": "new", "subject": "three"}, + ] + } + ), + encoding="utf-8", + ) + + with ( + patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=tmp_path, + ), + patch("aipass.hooks.apps.handlers.notification.email._speak"), + ): + result = handle({}) + + assert "3 new emails" in result["stdout"] + + +class TestCountNewEmails: + """Inbox counting logic tests.""" + + def test_counts_new_status(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import _count_new_emails + + inbox_dir = tmp_path / ".ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text( + json.dumps( + { + "messages": [ + {"status": "new"}, + {"status": "new"}, + {"status": "read"}, + ] + } + ), + encoding="utf-8", + ) + + assert _count_new_emails(tmp_path) == 2 + + def test_counts_unread_without_status(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import _count_new_emails + + inbox_dir = tmp_path / ".ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text( + json.dumps({"messages": [{"subject": "no status field"}]}), + encoding="utf-8", + ) + + assert _count_new_emails(tmp_path) == 1 + + def test_skips_read_messages(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import _count_new_emails + + inbox_dir = tmp_path / ".ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text( + json.dumps({"messages": [{"status": "read"}, {"read": True}]}), + encoding="utf-8", + ) + + assert _count_new_emails(tmp_path) == 0 + + def test_handles_bare_list_format(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import _count_new_emails + + inbox_dir = tmp_path / ".ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text( + json.dumps([{"status": "new"}, {"status": "read"}]), + encoding="utf-8", + ) + + assert _count_new_emails(tmp_path) == 1 + + def test_falls_back_to_legacy_path(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import _count_new_emails + + inbox_dir = tmp_path / "ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text( + json.dumps({"messages": [{"status": "new"}]}), + encoding="utf-8", + ) + + assert _count_new_emails(tmp_path) == 1 + + def test_returns_zero_when_no_inbox(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import _count_new_emails + + assert _count_new_emails(tmp_path) == 0 + + def test_returns_zero_on_corrupt_json(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import _count_new_emails + + inbox_dir = tmp_path / ".ai_mail.local" + inbox_dir.mkdir() + inbox_file = inbox_dir / "inbox.json" + inbox_file.write_text("not valid json{{{", encoding="utf-8") + + assert _count_new_emails(tmp_path) == 0 + + +class TestFindBranchRoot: + """Branch root discovery tests.""" + + def test_finds_branch_with_trinity(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import _find_branch_root + + trinity = tmp_path / ".trinity" + trinity.mkdir() + apps = tmp_path / "apps" + apps.mkdir() + + with ( + patch( + "aipass.hooks.apps.handlers.notification.email.Path.cwd", + return_value=tmp_path, + ), + patch( + "aipass.hooks.apps.handlers.notification.email._find_repo_root", + return_value=tmp_path.parent, + ), + ): + result = _find_branch_root() + + assert result == tmp_path + + def test_returns_none_when_no_markers(self): + from aipass.hooks.apps.handlers.notification.email import _find_branch_root + + with ( + patch( + "aipass.hooks.apps.handlers.notification.email.Path.cwd", + return_value=Path("/tmp/bare"), + ), + patch( + "aipass.hooks.apps.handlers.notification.email._find_repo_root", + return_value=None, + ), + ): + result = _find_branch_root() + + assert result is None + + def test_returns_none_at_repo_root(self, tmp_path): + from aipass.hooks.apps.handlers.notification.email import _find_branch_root + + with ( + patch( + "aipass.hooks.apps.handlers.notification.email.Path.cwd", + return_value=tmp_path, + ), + patch( + "aipass.hooks.apps.handlers.notification.email._find_repo_root", + return_value=tmp_path, + ), + ): + result = _find_branch_root() + + assert result is None + + +class TestSpeakFunction: + """Piper TTS tests.""" + + def test_speak_calls_piper_then_aplay(self): + from aipass.hooks.apps.handlers.notification.email import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.email.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.email.subprocess") as mock_sub, + patch("aipass.hooks.apps.handlers.notification.email.tempfile") as mock_tmp, + patch("aipass.hooks.apps.handlers.notification.email.Path") as mock_path, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + mock_sub.run.return_value = MagicMock(returncode=0) + mock_path.return_value.exists.return_value = True + + _speak("test text") + + mock_sub.run.assert_called_once() + mock_sub.Popen.assert_called_once() + + def test_speak_skips_when_piper_missing(self): + from aipass.hooks.apps.handlers.notification.email import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.email.subprocess") as mock_sub, + ): + mock_piper_bin.exists.return_value = False + _speak("test") + + mock_sub.run.assert_not_called() + + def test_speak_graceful_on_timeout(self): + import subprocess as real_sub + from aipass.hooks.apps.handlers.notification.email import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.email.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.email.subprocess") as mock_sub, + patch("aipass.hooks.apps.handlers.notification.email.tempfile") as mock_tmp, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5) + mock_sub.TimeoutExpired = real_sub.TimeoutExpired + + _speak("test") + + def test_speak_graceful_on_os_error(self): + from aipass.hooks.apps.handlers.notification.email import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.email.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.email.subprocess.run", side_effect=OSError("broken")), + patch("aipass.hooks.apps.handlers.notification.email.tempfile") as mock_tmp, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + + _speak("test") diff --git a/src/aipass/hooks/tests/test_git_gate.py b/src/aipass/hooks/tests/test_git_gate.py new file mode 100644 index 00000000..e1598cd2 --- /dev/null +++ b/src/aipass/hooks/tests/test_git_gate.py @@ -0,0 +1,135 @@ +# =================== AIPass ==================== +# Name: test_git_gate.py +# Version: 1.0.0 +# Description: Tests for git_gate security handler +# Branch: hooks +# Created: 2026-05-21 +# Modified: 2026-05-21 +# ============================================= + +"""Tests for handlers/security/git_gate.py.""" + +import json + + +class TestGitGateHandler: + def test_block_raw_git(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": "git status"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 2 + parsed = json.loads(result["stdout"]) + assert parsed["decision"] == "block" + assert "drone" in parsed["reason"] + + def test_block_raw_gh(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": "gh pr list"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 2 + + def test_allow_drone_git(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": "drone @git status"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_allow_gh_api(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": "gh api repos/owner/repo/pulls"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 0 + + def test_block_edit_settings(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle( + { + "tool_name": "Edit", + "tool_input": {"file_path": "/home/patrick/.claude/settings.json"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 2 + parsed = json.loads(result["stdout"]) + assert parsed["decision"] == "block" + + def test_allow_edit_settings_from_devpulse(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle( + { + "tool_name": "Edit", + "tool_input": {"file_path": "/home/patrick/.claude/settings.json"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", + } + ) + assert result["exit_code"] == 0 + + def test_block_edit_hooks_dir(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle( + { + "tool_name": "Edit", + "tool_input": {"file_path": "/home/patrick/Projects/AIPass/.claude/hooks/some_hook.py"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 2 + + def test_allow_normal_bash(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": "ls -la"}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_git_in_quoted_string(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": 'echo "git status"'}, + "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + } + ) + assert result["exit_code"] == 0 + + def test_empty_hook_data(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle({}) + assert result["exit_code"] == 0 diff --git a/src/aipass/hooks/tests/test_global_loader.py b/src/aipass/hooks/tests/test_global_loader.py new file mode 100644 index 00000000..4378e119 --- /dev/null +++ b/src/aipass/hooks/tests/test_global_loader.py @@ -0,0 +1,64 @@ +# =================== AIPass ==================== +# Name: test_global_loader.py +# Version: 1.0.0 +# Description: Tests for global_loader prompt handler +# Branch: hooks +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Tests for handlers/prompt/global_loader.py.""" + +from unittest.mock import patch + + +class TestGlobalLoaderHandler: + def test_loads_global_prompt(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.global_loader import handle + + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + prompt = aipass_dir / "aipass_global_prompt.md" + prompt.write_text("# AIPass Global\nContext here", encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"): + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + result = handle({}) + + assert result["exit_code"] == 0 + assert "AIPass Global" in result["stdout"] + assert "Context here" in result["stdout"] + + def test_returns_empty_when_no_aipass_home(self): + from aipass.hooks.apps.handlers.prompt.global_loader import handle + + with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"): + with patch.dict("os.environ", {}, clear=True): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_returns_empty_when_file_missing(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.global_loader import handle + + with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"): + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_empty_hook_data(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.global_loader import handle + + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "aipass_global_prompt.md").write_text("content", encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"): + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "content" diff --git a/src/aipass/hooks/tests/test_identity.py b/src/aipass/hooks/tests/test_identity.py new file mode 100644 index 00000000..b3fdc848 --- /dev/null +++ b/src/aipass/hooks/tests/test_identity.py @@ -0,0 +1,151 @@ +# =================== AIPass ==================== +# Name: test_identity.py +# Version: 1.0.0 +# Description: Tests for identity prompt handler +# Branch: hooks +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Tests for handlers/prompt/identity.py.""" + +import json +from pathlib import Path +from unittest.mock import patch, MagicMock + + +SAMPLE_PASSPORT = { + "branch_info": { + "branch_name": "devpulse", + "path": "src/aipass/devpulse", + "email": "unknown", + }, + "identity": { + "role": "orchestration_hub", + "purpose": "The user's primary AI collaborator", + "traits": ["Pragmatic", "Direct"], + "what_i_do": ["Plan", "Design", "Debug"], + "what_i_dont_do": ["Full rebuilds"], + }, + "principles": ["Fail honestly", "Memory is everything"], +} + + +class TestIdentityHandler: + def test_returns_identity_when_passport_found(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.identity import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + result = handle({"cwd": str(tmp_path)}) + + assert result["exit_code"] == 0 + assert "devpulse Identity" in result["stdout"] + assert "orchestration_hub" in result["stdout"] + assert "Pragmatic" in result["stdout"] + + def test_returns_empty_when_no_passport(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.identity import handle + + with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + result = handle({"cwd": str(tmp_path)}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_walks_up_to_find_passport(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.identity import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8") + nested = tmp_path / "apps" / "handlers" + nested.mkdir(parents=True) + + with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + result = handle({"cwd": str(nested)}) + + assert "devpulse Identity" in result["stdout"] + + def test_formats_all_fields(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.identity import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + result = handle({"cwd": str(tmp_path)}) + + out = result["stdout"] + assert "Path: src/aipass/devpulse" in out + assert "Email: unknown" in out + assert "Role: orchestration_hub" in out + assert "Purpose: The user's primary AI collaborator" in out + assert "Do: Plan | Design | Debug" in out + assert "Don't: Full rebuilds" in out + assert "Principles: Fail honestly * Memory is everything" in out + + def test_handles_minimal_passport(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.identity import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps({"branch_info": {"branch_name": "test"}, "identity": {}}), encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + result = handle({"cwd": str(tmp_path)}) + + assert result["exit_code"] == 0 + assert "test Identity" in result["stdout"] + + def test_empty_hook_data(self): + from aipass.hooks.apps.handlers.prompt.identity import handle + + with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_corrupt_passport_json(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.identity import handle + + trinity = tmp_path / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text("{broken json", encoding="utf-8") + + with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + result = handle({"cwd": str(tmp_path)}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + @patch("subprocess.Popen") + @patch("subprocess.run") + def test_piper_fires(self, mock_run, mock_popen): + from aipass.hooks.apps.handlers.prompt.identity import handle + + mock_run.return_value = MagicMock(returncode=0) + + with patch.object(Path, "exists", return_value=True): + handle({"cwd": "/tmp/nonexistent"}) + + assert mock_run.called or mock_popen.called + + def test_piper_skips_when_not_available(self): + from aipass.hooks.apps.handlers.prompt.identity import handle + + with patch("aipass.hooks.apps.handlers.prompt.identity.PIPER_BIN", Path("/nonexistent/piper")): + result = handle({"cwd": "/tmp/nonexistent"}) + + assert result["exit_code"] == 0 diff --git a/src/aipass/hooks/tests/test_rollover.py b/src/aipass/hooks/tests/test_rollover.py new file mode 100644 index 00000000..e16c3edf --- /dev/null +++ b/src/aipass/hooks/tests/test_rollover.py @@ -0,0 +1,107 @@ +# =================== AIPass ==================== +# Name: test_rollover.py +# Version: 1.0.0 +# Description: Tests for rollover lifecycle handler +# Branch: hooks +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Tests for handlers/lifecycle/rollover.py.""" + +import json +from unittest.mock import patch, MagicMock + + +class TestRolloverHandler: + def test_no_repo_root_returns_empty(self): + from aipass.hooks.apps.handlers.lifecycle.rollover import handle + + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=None): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_no_overdue_returns_empty(self): + from aipass.hooks.apps.handlers.lifecycle.rollover import handle + + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()): + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", return_value=[]): + result = handle({}) + + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_overdue_triggers_rollover(self): + from aipass.hooks.apps.handlers.lifecycle.rollover import handle + + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()): + with patch( + "aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", + return_value=[("devpulse", "local", "21/20 sessions")], + ): + with patch( + "aipass.hooks.apps.handlers.lifecycle.rollover._run_rollover", return_value=(True, "ok") + ): + result = handle({}) + + assert result["exit_code"] == 0 + + def test_check_file_v2_sessions_overdue(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file + + f = tmp_path / "local.json" + f.write_text( + json.dumps( + { + "document_metadata": {"limits": {"max_sessions": 5}}, + "sessions": [{"id": i} for i in range(6)], + } + ), + encoding="utf-8", + ) + + overdue, reason = _check_file(f) + assert overdue + assert "6/5" in reason + + def test_check_file_v2_not_overdue(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file + + f = tmp_path / "local.json" + f.write_text( + json.dumps( + { + "document_metadata": {"limits": {"max_sessions": 20}}, + "sessions": [{"id": i} for i in range(5)], + } + ), + encoding="utf-8", + ) + + overdue, _ = _check_file(f) + assert not overdue + + def test_check_file_v1_line_count(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file + + f = tmp_path / "obs.json" + content = {"document_metadata": {"limits": {"max_lines": 10}}} + text = json.dumps(content, indent=2) + lines_needed = 10 - text.count("\n") + text += "\n" * lines_needed + f.write_text(text, encoding="utf-8") + + overdue, reason = _check_file(f) + assert overdue + assert "lines" in reason + + def test_check_file_missing(self, tmp_path): + from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file + + overdue, _ = _check_file(tmp_path / "nonexistent.json") + assert not overdue diff --git a/src/aipass/hooks/tests/test_stop_sound.py b/src/aipass/hooks/tests/test_stop_sound.py new file mode 100644 index 00000000..a69c0481 --- /dev/null +++ b/src/aipass/hooks/tests/test_stop_sound.py @@ -0,0 +1,177 @@ +# =================== AIPass ==================== +# Name: test_stop_sound.py +# Version: 1.1.0 +# Description: Tests for stop_sound notification handler +# Branch: hooks +# Created: 2026-05-20 +# Modified: 2026-05-20 +# ============================================= + +"""Tests for handlers/notification/stop_sound.py.""" + +from unittest.mock import patch, MagicMock + + +class TestStopSoundHandler: + """Core handler behavior tests.""" + + def test_handle_returns_result_dict(self): + from aipass.hooks.apps.handlers.notification.stop_sound import handle + + with ( + patch("aipass.hooks.apps.handlers.notification.stop_sound._play"), + patch("aipass.hooks.apps.handlers.notification.stop_sound._speak"), + ): + result = handle({}) + + assert isinstance(result, dict) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_handle_speaks_stop_sound(self): + from aipass.hooks.apps.handlers.notification.stop_sound import handle + + with ( + patch("aipass.hooks.apps.handlers.notification.stop_sound._play"), + patch("aipass.hooks.apps.handlers.notification.stop_sound._speak") as mock_speak, + ): + handle({}) + + mock_speak.assert_called_once_with("stop sound") + + def test_handle_does_not_play_wav(self): + from aipass.hooks.apps.handlers.notification.stop_sound import handle + + with ( + patch("aipass.hooks.apps.handlers.notification.stop_sound._play") as mock_play, + patch("aipass.hooks.apps.handlers.notification.stop_sound._speak"), + ): + handle({}) + + mock_play.assert_not_called() + + def test_handle_skips_when_stop_hook_active(self): + from aipass.hooks.apps.handlers.notification.stop_sound import handle + + with patch("aipass.hooks.apps.handlers.notification.stop_sound._speak") as mock_speak: + result = handle({"stop_hook_active": True}) + + mock_speak.assert_not_called() + assert result["exit_code"] == 0 + + +class TestPlayFunction: + """WAV playback tests.""" + + def test_play_calls_aplay(self): + from aipass.hooks.apps.handlers.notification.stop_sound import _play + + mock_path = MagicMock() + mock_path.exists.return_value = True + + with patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess.Popen") as mock_popen: + _play(mock_path) + + mock_popen.assert_called_once() + args = mock_popen.call_args[0][0] + assert args[0] == "aplay" + assert args[1] == "-q" + + def test_play_skips_when_file_missing(self): + from aipass.hooks.apps.handlers.notification.stop_sound import _play + + mock_path = MagicMock() + mock_path.exists.return_value = False + + with patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess.Popen") as mock_popen: + _play(mock_path) + + mock_popen.assert_not_called() + + def test_play_graceful_on_os_error(self): + from aipass.hooks.apps.handlers.notification.stop_sound import _play + + mock_path = MagicMock() + mock_path.exists.return_value = True + + with patch( + "aipass.hooks.apps.handlers.notification.stop_sound.subprocess.Popen", + side_effect=OSError("broken"), + ): + _play(mock_path) + + +class TestSpeakFunction: + """Piper TTS tests.""" + + def test_speak_calls_piper_then_aplay(self): + from aipass.hooks.apps.handlers.notification.stop_sound import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess") as mock_sub, + patch("aipass.hooks.apps.handlers.notification.stop_sound.tempfile") as mock_tmp, + patch("aipass.hooks.apps.handlers.notification.stop_sound.Path") as mock_path, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + mock_sub.run.return_value = MagicMock(returncode=0) + mock_path.return_value.exists.return_value = True + + _speak("test text") + + mock_sub.run.assert_called_once() + mock_sub.Popen.assert_called_once() + + def test_speak_skips_when_piper_missing(self): + from aipass.hooks.apps.handlers.notification.stop_sound import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess") as mock_sub, + ): + mock_piper_bin.exists.return_value = False + _speak("test") + + mock_sub.run.assert_not_called() + + def test_speak_graceful_on_timeout(self): + import subprocess as real_sub + from aipass.hooks.apps.handlers.notification.stop_sound import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess") as mock_sub, + patch("aipass.hooks.apps.handlers.notification.stop_sound.tempfile") as mock_tmp, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5) + mock_sub.TimeoutExpired = real_sub.TimeoutExpired + + _speak("test") + + def test_speak_graceful_on_os_error(self): + from aipass.hooks.apps.handlers.notification.stop_sound import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess.run", side_effect=OSError("broken")), + patch("aipass.hooks.apps.handlers.notification.stop_sound.tempfile") as mock_tmp, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + + _speak("test") diff --git a/src/aipass/hooks/tests/test_subagent_gate.py b/src/aipass/hooks/tests/test_subagent_gate.py new file mode 100644 index 00000000..4cbcf400 --- /dev/null +++ b/src/aipass/hooks/tests/test_subagent_gate.py @@ -0,0 +1,149 @@ +# =================== AIPass ==================== +# Name: test_subagent_gate.py +# Version: 1.0.0 +# Description: Tests for subagent_gate security handler +# Branch: hooks +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Tests for handlers/security/subagent_gate.py.""" + +import json +from unittest.mock import patch, MagicMock + +from aipass.hooks.apps.handlers.security.subagent_gate import handle + + +class TestSubagentGateHandler: + def test_no_repo_root_allows(self): + with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): + with patch("aipass.hooks.apps.handlers.security.subagent_gate._speak"): + result = handle({"cwd": "/tmp/nowhere"}) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_no_modified_files_allows(self): + with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): + with patch("aipass.hooks.apps.handlers.security.subagent_gate._speak"): + result = handle({"cwd": "/tmp/somewhere"}) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + @patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None) + @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[]) + @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._speak") + def test_modified_files_no_violations_allows(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): + from pathlib import Path + + mock_root.return_value = Path("/fake/repo") + mock_modified.return_value = ["/fake/repo/src/aipass/hooks/apps/test.py"] + result = handle({"cwd": "/fake/repo/src/aipass/hooks"}) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + @patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None) + @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._speak") + def test_violations_blocks(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): + from pathlib import Path + + mock_root.return_value = Path("/fake/repo") + mock_modified.return_value = ["/fake/repo/src/aipass/hooks/apps/bad.py"] + mock_seedgo.return_value = ["Missing docstring", "No tests"] + result = handle({"cwd": "/fake/repo/src/aipass/hooks"}) + assert result["exit_code"] == 2 + parsed = json.loads(result["stdout"]) + assert parsed["decision"] == "block" + assert "Missing docstring" in parsed["reason"] + assert "No tests" in parsed["reason"] + assert "bad.py" in parsed["reason"] + + @patch("subprocess.run") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._speak") + def test_skip_claude_hooks_from_modified_files(self, mock_speak, mock_run, tmp_path): + + src = tmp_path / "src" / "aipass" / "hooks" + src.mkdir(parents=True) + (tmp_path / ".git").mkdir() + mock_run.return_value = MagicMock(stdout=" M .claude/hooks/something.py\n", returncode=0) + with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=tmp_path): + with patch("aipass.hooks.apps.handlers.security.subagent_gate._get_cwd_branch", return_value="hooks"): + from aipass.hooks.apps.handlers.security.subagent_gate import _get_modified_py_files + + files = _get_modified_py_files(str(src), tmp_path) + assert files == [] + + @patch("subprocess.run") + def test_skip_claude_hooks_from_seedgo_checks(self, mock_run): + from pathlib import Path + from aipass.hooks.apps.handlers.security.subagent_gate import _run_seedgo_checklist + + result = _run_seedgo_checklist("/repo/.claude/hooks/gate.py", Path("/repo")) + assert result == [] + mock_run.assert_not_called() + + @patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[]) + @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._speak") + def test_readme_accountability_advisory(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): + from pathlib import Path + + mock_root.return_value = Path("/fake/repo") + mock_modified.return_value = ["/fake/repo/src/aipass/hooks/apps/clean.py"] + mock_readme.return_value = ( + "Hook files were modified but .claude/hooks/README.md was not updated. " + "Consider updating the README to reflect your changes." + ) + result = handle({"cwd": "/fake/repo/src/aipass/hooks"}) + assert result["exit_code"] == 0 + parsed = json.loads(result["stdout"]) + assert parsed["decision"] == "allow" + assert "README" in parsed["reason"] + + @patch("subprocess.Popen") + @patch("subprocess.run") + def test_piper_fires_when_available(self, mock_run, mock_popen): + from pathlib import Path + from aipass.hooks.apps.handlers.security.subagent_gate import _speak + + mock_run.return_value = MagicMock(returncode=0) + with patch("aipass.hooks.apps.handlers.security.subagent_gate.PIPER_BIN", Path("/fake/piper")): + with patch("aipass.hooks.apps.handlers.security.subagent_gate.PIPER_VOICE", Path("/fake/voice.onnx")): + with patch("pathlib.Path.exists", return_value=True): + _speak("test") + mock_popen.assert_called_once() + + @patch("subprocess.Popen") + def test_piper_skips_when_not_available(self, mock_popen): + from pathlib import Path + from aipass.hooks.apps.handlers.security.subagent_gate import _speak + + with patch("aipass.hooks.apps.handlers.security.subagent_gate.PIPER_BIN", Path("/nonexistent/piper")): + _speak("test") + mock_popen.assert_not_called() + + def test_empty_hook_data_allows(self): + with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): + with patch("aipass.hooks.apps.handlers.security.subagent_gate._speak"): + result = handle({}) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._speak") + def test_exception_in_get_modified_allows(self, mock_speak, mock_root, mock_modified): + from pathlib import Path + + mock_root.return_value = Path("/fake/repo") + mock_modified.side_effect = RuntimeError("subprocess died") + result = handle({"cwd": "/fake/repo/src/aipass/hooks"}) + assert result["exit_code"] == 0 + assert result["stdout"] == "" diff --git a/src/aipass/seedgo/.aipass/aipass_local_prompt.md b/src/aipass/seedgo/.aipass/aipass_local_prompt.md index 1b5538f3..39ca7ce1 100644 --- a/src/aipass/seedgo/.aipass/aipass_local_prompt.md +++ b/src/aipass/seedgo/.aipass/aipass_local_prompt.md @@ -18,20 +18,13 @@ seedgo readme update @branch # README auto-update All modules also accept filename: `standards_audit`, `diagnostics_audit`, `readme_update`. Note: `proof`, `proof_query`, `test_map` currently not --help output (known TODO). -## Hook Ownership +## Hook Architecture -I own hooks. Canonical runtime location `~/.claude/hooks/` (Anthropic global level — hooks must work across all projects, not per-project). Project-level `.claude/` settings only. Inventory: +The **hooks branch** (`src/aipass/hooks/`) owns all hook infrastructure — engine, bridge, and 14 native handlers. Seedgo audits hooks via standards but does not own the hook system. -- **auto_fix_diagnostics.py** (PostToolUse Edit/Write/NotebookEdit) — runs py_compile + ruff + pattern checks + `drone @seedgo checklist` + pyright on edited file, surfaces errors `additionalContext`, saves type errors state file edit gate -- **pre_edit_gate.py** (PreToolUse Edit/Write) — blocks edits OTHER files while type error exists current file (branch-scoped — cross-branch edits allowed) -- **subagent_stop_gate.py** — SubagentStop gate. Built, NOT wired settings.json 2026-04-14. Runs seedgo checklist all modified .py files, blocks sub-agent stop until clean. DevPass enforcement pattern. DPLAN-0131 discusses wiring. -- **branch_prompt_loader.py** (UserPromptSubmit) — injects `.aipass/aipass_local_prompt.md` when CWD branch -- **identity_injector.py** (UserPromptSubmit) — injects passport identity block -- **email_notification.py** (UserPromptSubmit) — inbox banner -- **pre_compact.py** (PreCompact manual+auto) — memory archival prep -- Sounds (tool_use, stop, notification) — sound effects. Hook-sounds plugin itself drone's territory. +Provider settings route all events through the bridge: `src/aipass/hooks/apps/handlers/bridges/claude.py :`. The bridge dispatches to native Python handlers in `hooks/apps/handlers/` (prompt, security, lifecycle, notification categories). -Three locations drift today: `~/.claude/hooks/` (runtime), `AIPass/.claude/hooks/` (project-level copies), `AIPass/.claude/global_hooks/` (orphaned drift — `auto_fix_diagnostics.py` 35 lines behind). Consolidation part DPLAN-0131. +Seedgo's bridge installer (`drone @seedgo bridge install`) manages hook installation to `~/.claude/settings.json`. ## Apps Layout (extra layer vs standard branch) diff --git a/src/aipass/seedgo/README.md b/src/aipass/seedgo/README.md index fe2c7816..d9289cc4 100644 --- a/src/aipass/seedgo/README.md +++ b/src/aipass/seedgo/README.md @@ -2,7 +2,7 @@ # Seedgo -**Purpose:** Standards compliance platform for AIPass. Audits all 11 core agents against 35 code standards + diagnostics, manages bypass rules, runs proof certification, owns the hook system, and provides per-file checklist validation consumed by auto-fix hooks. +**Purpose:** Standards compliance platform for AIPass. Audits all 11 core agents against 35 code standards + diagnostics, manages bypass rules, runs proof certification, and provides per-file checklist validation consumed by auto-fix hooks. **Module:** `aipass.seedgo` **Version:** 2.0.0 **Created:** 2026-03-05 @@ -16,7 +16,7 @@ - Score files 0-100 per standard and report violations with actionable details - Manage bypass rules (`.seedgo/bypass.json`) for deliberate exceptions - Run pyright diagnostics across branches for type error detection -- Own the hook system: auto-fix diagnostics, edit gate, subagent stop gate, bridge installer +- Hook bridge installer (`drone @seedgo bridge install`) for `~/.claude/settings.json` management - Single-file checklist validation against all standards (consumed by PostToolUse auto-fix hook) - Proof certification via proof/proof_query (triplet, plugin integrity, README currency) - Custom function test coverage mapping via test_map @@ -198,24 +198,30 @@ seedgo/ --- -## Hook Ownership +## Hook Architecture -Seedgo owns the AIPass hook system. Canonical runtime location: `AIPass/.claude/hooks/`. +The **hooks branch** (`src/aipass/hooks/`) owns all hook infrastructure — engine, bridge, and 14 native handlers. Seedgo audits hooks via standards but does not own the hook system. -| Hook | Event | What It Does | -|------|-------|--------------| -| auto_fix_diagnostics.py v5.2.0 | PostToolUse (Edit/Write) | py_compile + ruff + pattern checks + `drone @seedgo checklist` + pyright. Saves state for edit gate | -| pre_edit_gate.py v1.3.0 | PreToolUse (Edit/Write) | Blocks edits to other files while type errors exist. Cross-branch inbox write protection | -| subagent_stop_gate.py v1.0 | SubagentStop | Runs checklist on modified .py files, blocks stop until clean | -| branch_prompt_loader.py | UserPromptSubmit | Injects `.aipass/aipass_local_prompt.md` when CWD is in a branch | -| identity_injector.py | UserPromptSubmit | Injects passport identity block | -| email_notification.py | UserPromptSubmit | Inbox banner | -| pre_compact.py | PreCompact | Memory archival prep | -| notification_sound.py | Notification | Sound effect | -| stop_sound.py | Stop | Sound effect | -| tool_use_sound.py | PreToolUse | Sound effect | +Provider settings route all events through the bridge (`claude.py`), which dispatches to native Python handlers: -The `bridge` module (`drone @seedgo bridge install`) manages hook installation to `~/.claude/settings.json` using the AIPASS_HOOK_MANIFEST (13 entries across 7 events). +| Handler | Event | Category | +|---------|-------|----------| +| `prompt.global_loader` | UserPromptSubmit | prompt | +| `prompt.branch_loader` | UserPromptSubmit | prompt | +| `prompt.identity` | UserPromptSubmit | prompt | +| `notification.email` | UserPromptSubmit | notification | +| `security.edit_gate` | PreToolUse | security | +| `security.git_gate` | PreToolUse | security | +| `lifecycle.auto_fix` | PostToolUse | lifecycle | +| `lifecycle.auto_watchdog` | PostToolUse | lifecycle | +| `security.subagent_gate` | SubagentStop | security | +| `notification.stop_sound` | Stop | notification | +| `notification.announce` | Notification | notification | +| `notification.tool_sound` | PreToolUse | notification | +| `lifecycle.compact` | PreCompact | lifecycle | +| `lifecycle.rollover` | PreCompact | lifecycle | + +The `bridge` module (`drone @seedgo bridge install`) manages hook installation to `~/.claude/settings.json`. --- @@ -247,7 +253,8 @@ The `bridge` module (`drone @seedgo bridge install`) manages hook installation t ## Known Issues / Tech Debt - `audit_display.py`: 16 hardcoded display blocks for specific standards (DPLAN-0047 tracks dynamic refactor) -- Hook location drift: `~/.claude/hooks/` vs `AIPass/.claude/hooks/` (DPLAN-0131) +- `bridge_handler.py` AIPASS_HOOK_MANIFEST references old script paths — needs update to bridge architecture +- `test_hooks_track_a/b/e.py` import old hook scripts from `.claude/hooks/` — should migrate to test new native handlers - `proof`, `proof_query`, `test_map` not listed in `--help` output - `documentation_check.py` 5-line lookahead limitation for multi-line function signatures - `dead_code_check.py` doesn't recognize `iterdir()` as valid discovery pattern diff --git a/src/aipass/seedgo/apps/handlers/hooks/bridge_handler.py b/src/aipass/seedgo/apps/handlers/hooks/bridge_handler.py index b17119a3..63aab247 100644 --- a/src/aipass/seedgo/apps/handlers/hooks/bridge_handler.py +++ b/src/aipass/seedgo/apps/handlers/hooks/bridge_handler.py @@ -82,7 +82,12 @@ def write_settings(path: Path, data: dict) -> bool: # Hook detection # --------------------------------------------------------------------------- -_AIPASS_COMMAND_MARKERS = ("AIPass/.claude/hooks/", "$AIPASS_HOME", "aipass_global_prompt") +_AIPASS_COMMAND_MARKERS = ( + "AIPass/.claude/hooks/", + "$AIPASS_HOME", + "aipass_global_prompt", + "aipass/hooks/apps/handlers/bridges/claude.py", +) def is_aipass_hook_entry(entry: dict) -> bool: @@ -111,134 +116,79 @@ def count_aipass_hooks(settings: dict) -> int: # Hook manifest — the canonical set of AIPass hooks # --------------------------------------------------------------------------- +_BRIDGE = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py" + AIPASS_HOOK_MANIFEST: dict[str, list[dict]] = { "UserPromptSubmit": [ { "_aipass": True, - "hooks": [ - { - "type": "command", - "command": "cat $AIPASS_HOME/.aipass/aipass_global_prompt.md 2>/dev/null || true", - } - ], + "hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:global_prompt"}], }, { "_aipass": True, - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/branch_prompt_loader.py", - } - ], + "hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:branch_prompt"}], }, { "_aipass": True, - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/identity_injector.py", - } - ], + "hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:identity_injector"}], }, { "_aipass": True, - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/email_notification.py", - } - ], + "hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:email_notification"}], }, ], "PreToolUse": [ { "_aipass": True, "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/tool_use_sound.py", - } - ], - }, - { - "_aipass": True, - "matcher": "Edit|MultiEdit|Write|NotebookEdit", - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/pre_edit_gate.py", - } - ], + "hooks": [{"type": "command", "command": f"{_BRIDGE} PreToolUse"}], }, ], "PostToolUse": [ { "_aipass": True, - "matcher": "Edit|MultiEdit|Write|NotebookEdit", - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/auto_fix_diagnostics.py", - } - ], + "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", + "hooks": [{"type": "command", "command": f"{_BRIDGE} PostToolUse"}], }, ], "SubagentStop": [ { "_aipass": True, - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/subagent_stop_gate.py", - } - ], + "hooks": [{"type": "command", "command": f"{_BRIDGE} SubagentStop"}], }, ], "Stop": [ { "_aipass": True, - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/stop_sound.py", - } - ], + "hooks": [{"type": "command", "command": f"{_BRIDGE} Stop"}], }, ], "Notification": [ { "_aipass": True, - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/notification_sound.py", - } - ], + "hooks": [{"type": "command", "command": f"{_BRIDGE} Notification"}], }, ], "PreCompact": [ { "_aipass": True, "matcher": "manual", - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/pre_compact.py", - "timeout": 60, - } - ], + "hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact", "timeout": 60}], }, { "_aipass": True, "matcher": "auto", - "hooks": [ - { - "type": "command", - "command": "python3 $AIPASS_HOME/.claude/hooks/pre_compact.py", - "timeout": 60, - } - ], + "hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact", "timeout": 60}], + }, + { + "_aipass": True, + "matcher": "manual", + "hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact_rollover", "timeout": 120}], + }, + { + "_aipass": True, + "matcher": "auto", + "hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact_rollover", "timeout": 120}], }, ], } diff --git a/src/aipass/seedgo/tests/fixtures/branch_hooks_snapshot.json b/src/aipass/seedgo/tests/fixtures/branch_hooks_snapshot.json index 77dbc6f3..0967ef42 100644 --- a/src/aipass/seedgo/tests/fixtures/branch_hooks_snapshot.json +++ b/src/aipass/seedgo/tests/fixtures/branch_hooks_snapshot.json @@ -1,10 +1 @@ -{ - "UserPromptSubmit": [ - {"hooks": [{"type": "command", "command": "python3 .claude/hooks/branch_prompt_loader.py"}]}, - {"hooks": [{"type": "command", "command": "python3 .claude/hooks/email_notification.py"}]}, - {"hooks": [{"type": "command", "command": "python3 .claude/hooks/identity_injector.py"}]} - ], - "PreCompact": [ - {"hooks": [{"type": "command", "command": "python3 .claude/hooks/pre_compact.py"}]} - ] -} +{} diff --git a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json index 57f487d6..05f4c055 100644 --- a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json +++ b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json @@ -1,31 +1,130 @@ { "UserPromptSubmit": [ - {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/global_prompt_loader.py"}]}, - {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/branch_prompt_loader.py"}]}, - {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/identity_injector.py"}]}, - {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/email_notification.py"}]} + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:global_prompt" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification" + } + ] + } ], "PreToolUse": [ - {"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/tool_use_sound.py"}]}, - {"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "hooks": [{"type": "command", "command": "/home/patrick/Projects/AIPass/.venv/bin/python3 /home/patrick/Projects/AIPass/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse"}]}, - {"matcher": "Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/pre_edit_gate.py"}]}, - {"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/git_gate.py"}]} + { + "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse" + } + ] + } ], "PostToolUse": [ - {"matcher": "Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/auto_fix_diagnostics.py"}]}, - {"matcher": "Bash", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/auto_watchdog.py"}]} + { + "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse" + } + ] + } ], "SubagentStop": [ - {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/subagent_stop_gate.py"}]} + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop" + } + ] + } ], - "Stop": [], "Notification": [ - {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/notification_sound.py"}]} + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification" + } + ] + } ], "PreCompact": [ - {"matcher": "manual", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact.py", "timeout": 60}]}, - {"matcher": "auto", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact.py", "timeout": 60}]}, - {"matcher": "manual", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact_rollover.py", "timeout": 120}]}, - {"matcher": "auto", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact_rollover.py", "timeout": 120}]} + { + "matcher": "manual", + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", + "timeout": 60 + } + ] + }, + { + "matcher": "auto", + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", + "timeout": 60 + } + ] + }, + { + "matcher": "manual", + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", + "timeout": 120 + } + ] + }, + { + "matcher": "auto", + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", + "timeout": 120 + } + ] + } ] } diff --git a/src/aipass/seedgo/tests/test_hooks_snapshot.py b/src/aipass/seedgo/tests/test_hooks_snapshot.py index 2b0ba1ca..35c81ade 100644 --- a/src/aipass/seedgo/tests/test_hooks_snapshot.py +++ b/src/aipass/seedgo/tests/test_hooks_snapshot.py @@ -58,17 +58,10 @@ def _normalize_command(cmd: str) -> str: so snapshots are comparable across machines (Linux vs Windows CI). Keeps the interpreter and script name, removes path prefixes. """ - # Replace Windows backslashes with forward slashes first cmd = cmd.replace("\\", "/") - # Strip any absolute prefix up to and including the repo name - # e.g. "python3 /home/patrick/Projects/AIPass/.claude/hooks/x.py" - # -> "python3 .claude/hooks/x.py" - # e.g. "python3 D:/a/AIPass/AIPass/.claude/hooks/x.py" - # -> "python3 .claude/hooks/x.py" - cmd = re.sub(r"(?<= )([A-Za-z]:)?/.+?/AIPass/", "", cmd) - # Also strip home-dir provider hooks path: - # "python3 /home/patrick/.claude/hooks/x.py" -> "python3 .claude/hooks/x.py" - cmd = re.sub(r"(?<= )([A-Za-z]:)?/.+?/\.claude/", ".claude/", cmd) + cmd = re.sub(r"\$AIPASS_HOME/", "", cmd) + cmd = re.sub(r"(?:(?<=^)|(?<= ))([A-Za-z]:)?/.+?/AIPass/", "", cmd) + cmd = re.sub(r"(?:(?<=^)|(?<= ))([A-Za-z]:)?/.+?/\.claude/", ".claude/", cmd) return cmd