feat: DPLAN-0184 Phase 2 complete + DPLAN-0186 post-migration sweep — 14 native handlers, bridge routing, docs/setup alignment

This commit is contained in:
AIOSAI
2026-05-22 14:53:14 -07:00
parent 7df4f57bd4
commit adb85b03a8
61 changed files with 4792 additions and 2716 deletions
+1 -1
View File
@@ -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.
+104 -126
View File
@@ -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/<domain>/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`.
+72 -154
View File
@@ -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** | `<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 <name> # 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 `(?<![@\w/.])` excludes `/` before `git`
3. **Nested bash with quotes** — `bash -c 'git log'`
Hook strips quoted strings before scanning, so `git` inside quotes is invisible
4. **Script file execution** — Write git commands to `/tmp/script.sh`, then run it
`git` is inside the file content, not the Bash command
5. **Subshell expansion** — `$(which git) status`
Hook sees `$(which git)` not bare `git`
### Why this is acceptable
These bypasses require deliberate circumvention — no agent will accidentally hit them.
The hook catches all natural/obvious git usage patterns. Combined with the drone tier
system (only devpulse has write-level git access), the defense is layered.
## Related
- **DPLAN-0173** — Git workflow redesign (whitelist-only drone git)
- **DPLAN-0167** — Hook testing framework
- **DPLAN-0166** — Hook audit + CI health
- **DPLAN-0139** — Hook overhaul + single-path enforcement
- **DPLAN-0131** — Hook system alignment (seedgo ownership)
- **DPLAN-0184** -- Hook migration (standalone scripts to native handlers)
- **DPLAN-0167** -- Hook testing framework
- **DPLAN-0166** -- Hook audit + CI health
- **DPLAN-0139** -- Hook overhaul + single-path enforcement
- **DPLAN-0053** -- Original hook architecture research
-390
View File
@@ -1,390 +0,0 @@
#!/usr/bin/env python3
"""
PostToolUse Auto-fix Hook — Detects errors and surfaces them for fixing.
Two-hook system:
PostToolUse (this file) → runs pyright + ruff on edited file, saves errors to state
PreToolUse (pre_edit_gate.py) → blocks edits to OTHER files until errors fixed
Key behaviors:
- Runs py_compile (syntax), ruff lint+format, pyright (type errors) on edited file
- Runs seedgo checklist for AIPass standards
- Saves ruff lint AND pyright errors to state file for PreToolUse gate (hard block)
- Surfaces ALL errors in additionalContext so Claude sees them
Version: 5.2.0
CHANGELOG:
- v5.2.0 (2026-04-20): Save ruff lint errors to state file for hard-block enforcement.
Pre-edit gate now blocks on F401/lint just like type errors.
- v5.1.0 (2026-04-19): Added ruff format --check to surface format drift.
- v5.0.0 (2026-03-17): Replaced mcp__ide__getDiagnostics with direct pyright.
Added state file for PreToolUse gate integration.
Single-file pyright (not whole project).
- v4.3.0 (2026-03-17): Added seedgo checklist integration
- v4.0.0 (2025-11-27): Complete rewrite - actual validation, silent operation
"""
import json
import sys
import subprocess
from pathlib import Path
EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"]
LAST_FILE_PATH = Path(__file__).parent / ".last_diagnostics_file"
STATE_FILE = Path(__file__).parent / ".diagnostics_state.json"
SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"}
# AIPass-specific Python patterns to check
PYTHON_PATTERNS = {
"bad_optional": {"pattern": ": str = None", "message": "Optional param should use 'str | None = None' pattern"},
"logger_debug": {
"pattern": "logger.debug(",
"message": "Use logger.info for SystemLogger (logger.debug not supported)",
},
"return_error_msg": {
"pattern": "return error_msg",
"message": "Return None for error states, not error_msg string",
},
"open_no_encoding": {
"pattern": "open(",
"requires_missing": "encoding=",
"message": "open() without encoding='utf-8'",
},
"log_not_log_operation": {
"pattern": ".log(",
"message": "Use log_operation() with success/error params, not .log()",
},
"dict_none_no_check": {
"pattern": "Dict | None",
"message": "Dict | None return: Add None check before using (if result is None: return)",
},
}
# JSON-specific patterns for emoji corruption
JSON_CORRUPTION_CHARS = ["\ufffd", "\x00"]
def run_python_checks(file_path: str) -> list[str]:
"""Run actual Python validation - returns list of errors."""
errors = []
# 1. Syntax check with py_compile
try:
result = subprocess.run(
[sys.executable, "-m", "py_compile", file_path], capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
errors.append(f"SYNTAX: {result.stderr.strip()}")
except Exception:
pass
# 2. Ruff check (if available) - fast linter
try:
result = subprocess.run(
["ruff", "check", "--select=E,F,W", "--output-format=text", file_path],
capture_output=True,
text=True,
timeout=10,
)
if result.stdout.strip():
for line in result.stdout.strip().split("\n")[:5]:
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)
-53
View File
@@ -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()
-89
View File
@@ -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<!-- Source: {prompt_file} -->\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)
-125
View File
@@ -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 <id> | close with: drone @ai_mail close <id>"
)
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)
-215
View File
@@ -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 <EventType>
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 <EventType>\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()
-54
View File
@@ -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()
-40
View File
@@ -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()
-253
View File
@@ -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"(?<![@\w/.])git\s+(?:--?[A-Za-z][A-Za-z0-9_-]*(?:[= ][^\s]+)?\s+)*"
r"(" + "|".join(BLOCKED_GIT_VERBS) + r")\b"
)
# Full binary path bypass: /usr/bin/git, /usr/local/bin/git, ./git, etc.
BLOCKED_GIT_PATH_RE = re.compile(
r"/git\s+(?:--?[A-Za-z][A-Za-z0-9_-]*(?:[= ][^\s]+)?\s+)*"
r"(" + "|".join(BLOCKED_GIT_VERBS) + r")\b"
)
BLOCKED_GIT_STASH_RE = re.compile(r"(?<![@\w/.])git\s+stash\s+(drop|clear|pop|apply)\b")
BLOCKED_GIT_BRANCH_RE = re.compile(
r"(?<![@\w/.])git\s+branch\s+.*(-[dDmMcC]\b|--delete|--move|--copy|--force|--set-upstream-to|--unset-upstream)"
)
BLOCKED_GIT_TAG_RE = re.compile(r"(?<![@\w/.])git\s+tag\s+.*(-d\b|--delete|--force|-f\b)")
BLOCKED_GIT_REMOTE_RE = re.compile(
r"(?<![@\w/.])git\s+remote\s+(add|remove|rename|set-url|set-branches|set-head|prune)\b"
)
BLOCKED_GH_API_RE = re.compile(
r"(?<![@\w/.])gh\s+api\b.*("
r"-X\s+(POST|PUT|PATCH|DELETE)"
r"|--method\s+(POST|PUT|PATCH|DELETE)"
r"|-f\b|--field\b|-F\b|--raw-field\b|--input\b"
r")"
)
BLOCKED_GH_RE = re.compile(
r"(?<![@\w/.])gh\s+(pr|issue|repo|release|workflow|run|cache|secret|variable|gist)"
r"\s+(?!list\b|view\b|status\b|diff\b|checks\b|comments\b)\w[\w-]*"
)
# Full binary path for gh: /usr/bin/gh, /usr/local/bin/gh, etc.
BLOCKED_GH_PATH_RE = re.compile(
r"/gh\s+(pr|issue|repo|release|workflow|run|cache|secret|variable|gist)"
r"\s+(?!list\b|view\b|status\b|diff\b|checks\b|comments\b)\w[\w-]*"
)
BLOCKED_EDIT_PATTERNS = [
re.compile(r"/\.claude/settings(\.local)?\.json$"),
re.compile(r"/\.claude/hooks/"),
re.compile(r"/\.git/hooks/"),
]
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
# Branches trusted to edit the enforcement layer itself (mirrors pre_edit_gate).
TRUSTED_HOOK_EDITORS = ("devpulse", "seedgo")
GIT_REDIRECT = (
"Raw git write commands are blocked. Use drone instead:\n"
" drone @git status # what changed\n"
" drone @git diff # see changes\n"
" drone @git log # commit history\n"
" drone @git commit 'msg' --all # commit all changes (devpulse only)\n"
' drone @git dev-pr "description" # PR dev to main (devpulse only)\n'
" drone @git smart-sync # fetch + rebase\n"
"Read-only git (status, log, diff, show, fetch, ls-files) is allowed."
)
GH_REDIRECT = (
"Raw gh write commands are blocked. Use drone for git ops:\n"
' drone @git dev-pr "description" # PR dev to main\n'
" drone @git merge <PR#> # 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|(?<!\w)popen\s*\(|(?<!\w)system\s*\(", cmd):
if re.search(r"\bgit\b|\bgh\b", cmd):
_block(GIT_REDIRECT)
# Script-file bypass detection — read file contents when an interpreter runs a file.
script_match = re.search(
r"(?:^|[;&|]\s*)(?:bash|sh|source|python3?|\.)\s+([^\s;&|]+)", cmd
)
if script_match:
script_path = script_match.group(1)
if not script_path.startswith("-"):
if not os.path.isabs(script_path):
script_path = os.path.join(cwd, script_path)
try:
content = Path(script_path).read_text(encoding="utf-8", errors="ignore")
if BLOCKED_GIT_RE.search(content) or BLOCKED_GIT_PATH_RE.search(content):
_block(GIT_REDIRECT)
if re.search(r"\bgit\b|\bgh\b", content):
if re.search(r"subprocess|os\.system|os\.popen|Popen", content):
_block(GIT_REDIRECT)
if BLOCKED_GH_RE.search(content) or BLOCKED_GH_PATH_RE.search(content):
if not (_cwd_branch(cwd) in TRUSTED_HOOK_EDITORS or _is_project_owner(cwd)):
_block(GH_REDIRECT)
except (OSError, UnicodeDecodeError):
pass
# Pipe-to-shell and stdin redirect: cat file | bash, bash < file
pipe_match = re.search(r"(?:cat|head|tail)\s+([^\s;&|]+)\s*\|\s*(?:bash|sh)\b", cmd)
if not pipe_match:
pipe_match = re.search(r"(?:bash|sh)\s*<\s*([^\s;&|]+)", cmd)
if pipe_match:
piped_path = pipe_match.group(1)
if not os.path.isabs(piped_path):
piped_path = os.path.join(cwd, piped_path)
try:
content = Path(piped_path).read_text(encoding="utf-8", errors="ignore")
if BLOCKED_GIT_RE.search(content) or BLOCKED_GIT_PATH_RE.search(content):
_block(GIT_REDIRECT)
except (OSError, UnicodeDecodeError):
pass
# xargs with git/gh — command construction bypass
if re.search(r"\bxargs\b.*\bgit\b|\bxargs\b.*\bgh\b", cmd):
_block(GIT_REDIRECT)
# Variable assignment bypass: cmd=git; $cmd commit
if re.search(r"=\s*git\b|=\s*gh\b", cmd):
if re.search(r"\$\w*\s+" + "(" + "|".join(BLOCKED_GIT_VERBS) + ")", cmd):
_block(GIT_REDIRECT)
# Strip quoted strings before matching — text inside "..." or '...' is data
# (PR descriptions, commit messages, examples in docs), not code to enforce.
scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd)
scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", cmd)
if (
BLOCKED_GIT_RE.search(scan)
or BLOCKED_GIT_PATH_RE.search(scan)
or BLOCKED_GIT_STASH_RE.search(scan)
or BLOCKED_GIT_BRANCH_RE.search(scan)
or BLOCKED_GIT_TAG_RE.search(scan)
or BLOCKED_GIT_REMOTE_RE.search(scan)
):
_block(GIT_REDIRECT)
if BLOCKED_GH_API_RE.search(scan) or BLOCKED_GH_RE.search(scan) or BLOCKED_GH_PATH_RE.search(scan):
if not (_cwd_branch(cwd) in TRUSTED_HOOK_EDITORS or _is_project_owner(cwd)):
_block(GH_REDIRECT)
return
if tool_name in EDIT_TOOLS:
file_path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
if not file_path:
return
for pat in BLOCKED_EDIT_PATTERNS:
if pat.search(file_path):
# Trusted-editor bypass: devpulse working from its own branch
# is the maintainer of the enforcement layer.
if _cwd_branch(cwd) in TRUSTED_HOOK_EDITORS:
return
_block(EDIT_REDIRECT.format(path=file_path))
return
except Exception:
return
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)
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""
Global Prompt Loader — replaces hardcoded `cat` of aipass_global_prompt.md.
Uses $AIPASS_HOME for path portability. Exits silently when CWD is inside
a project that has its own UserPromptSubmit hooks (avoids injecting the
22KB AIPass source-tree prompt into standalone projects).
Version: 1.0.0
"""
import json
import os
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 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)
-147
View File
@@ -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)
-41
View File
@@ -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)
-184
View File
@@ -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)
-149
View File
@@ -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 @<branch> "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()
+19 -14
View File
@@ -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.
-25
View File
@@ -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
-42
View File
@@ -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)
-189
View File
@@ -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/<name>) 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)
-44
View File
@@ -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)
+2 -2
View File
@@ -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
+48 -48
View File
@@ -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) ---
+4 -2
View File
@@ -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 <PR#> # Merge a PR and sync local main
drone @git delete-branch <name> # Delete a remote branch (not main/dev)
drone @git close-pr <number> # 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:<slug>`), 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
+5 -1
View File
@@ -567,6 +567,8 @@ def get_help(command: str | None = None) -> str:
return (
"git delete-branch <name> — Delete a remote branch [owner]\n Protected: main and dev cannot be deleted.\n"
)
if command == "close-pr":
return "git close-pr <number> — Close a GitHub pull request by number [owner]\n"
if command == "commit":
return (
"git commit <message> [--all | file1 file2 ...] — Commit changes [owner]\n"
@@ -625,6 +627,7 @@ def get_help(command: str | None = None) -> str:
" pr <desc> Push current branch and create PR to main\n"
" dev-pr <desc> Push dev and create PR to main\n"
" delete-branch <name> Delete a remote branch\n"
" close-pr <number> Close a PR\n"
" merge <PR#> 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 <cmd> [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"
)
@@ -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")
+79 -59
View File
@@ -1,87 +1,107 @@
# HOOKS — Branch Prompt
# HOOKS -- Branch Prompt
<!--
INSTRUCTIONS FOR FILLING OUT THIS TEMPLATE
==========================================
This file is your local prompt. It gets injected every turn alongside the global prompt.
The global prompt already covers: dispatch syntax, git workflow, hard rules, logging,
memory system, breadcrumbs philosophy, and system-wide commands. Don't repeat any of that here.
Your job: fill out each section below with content specific to YOUR branch.
Replace the guidance text (in italics) with real content, then delete this instruction block.
PRINCIPLES:
- Breadcrumbs, not encyclopedias. Enough to navigate, not everything there is to know.
- Operational, not descriptive. Tell the agent how to ACT, not just what things ARE.
- Include concrete reference data — lookup tables, checklists, decision trees.
- If you can run `drone @hooks --help` to get it, don't duplicate it here.
- This file should be STABLE. If it changes every session, that content belongs in
STATUS.local.md or .trinity/ instead.
WHEN YOU'RE DONE:
- Delete this instruction block
- Delete all italic guidance text
- What remains should be pure operational reference
-->
*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.
+281 -1
View File
@@ -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": {
@@ -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 <EventType>\n")
sys.stderr.write("Usage: claude.py <EventType> or claude.py <EventType:hook_name>\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)
@@ -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}
@@ -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}
@@ -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}
@@ -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}
@@ -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}
@@ -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 <id> | close with: drone @ai_mail close <id>"
logger.info("[HOOKS] email: %d new email%s", new_count, plural)
return {"stdout": msg, "exit_code": 0}
@@ -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}
@@ -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<!-- Source: {prompt_file} -->\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}
@@ -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}
@@ -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}
@@ -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 @<branch> "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}
@@ -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"(?<![@\w/.])git\s")
RAW_GH_RE = re.compile(r"(?<![@\w/.])gh\s")
GH_ALLOWED_SUBCOMMANDS = ("api",)
BLOCKED_EDIT_PATTERNS = [
re.compile(r"/\.claude/settings(\.local)?\.json$"),
re.compile(r"/\.claude/hooks/"),
re.compile(r"/\.git/hooks/"),
]
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
TRUSTED_HOOK_EDITORS = ("devpulse", "seedgo")
GIT_GH_REDIRECT = (
"All git/gh commands are blocked. Use drone instead:\n"
" drone @git status # working tree status\n"
" drone @git diff # see changes\n"
" drone @git log # commit history\n"
" drone @git smart-sync # fetch + rebase\n"
" drone @git sync # checkout main + pull\n"
" drone @git issue list # GitHub issues\n"
" drone @git run list # CI runs\n"
" drone @git workflow run # trigger workflows"
)
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."
)
_BLOCK_ALLOW = {"stdout": "", "exit_code": 0}
def _cwd_branch(cwd: str) -> 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"(?<![@\w/.])gh\s+(\w+)", cmd)
if match:
return match.group(1) in GH_ALLOWED_SUBCOMMANDS
return False
def _block(reason: str) -> 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
@@ -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/<name>) 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
+168
View File
@@ -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")
+427
View File
@@ -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
@@ -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
@@ -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"]
+88
View File
@@ -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
+122
View File
@@ -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
+380
View File
@@ -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")
+135
View File
@@ -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
@@ -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"
+151
View File
@@ -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
+107
View File
@@ -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
+177
View File
@@ -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")
@@ -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"] == ""
@@ -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 <Event>:<handler>`. 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)
+25 -18
View File
@@ -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
@@ -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}],
},
],
}
+1 -10
View File
@@ -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"}]}
]
}
{}
+116 -17
View File
@@ -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
}
]
}
]
}
+3 -10
View File
@@ -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