Merge pull request #604 from AIOSAI/dev

settings merge on update, gitignore cleanup, dead code removal, setup.sh rollover hooks
This commit is contained in:
AIPass
2026-05-23 22:58:09 -07:00
committed by GitHub
133 changed files with 7320 additions and 3120 deletions
+1
View File
@@ -1,4 +1,5 @@
*
!aipass_global_prompt.md
!hooks.json
!.gitignore
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
+7 -3
View File
@@ -79,15 +79,19 @@ 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.
Linting and formatting run automatically on commit via drone's commit handler (ruff check --fix + ruff format).
# aipass init
# aipass CLI
Bootstraps AIPass project in any directory, inside or outside repo. Creates registry, identity, memory, local prompt. Any folder becomes AI-powered workspace with persistent memory. Spawn adds full agent scaffolding on top.
`aipass` = standalone binary (`/usr/local/bin/aipass`). User-facing tool — not drone-routed. Users run `aipass` directly without knowing about drone.
Commands: `aipass init`, `aipass doctor`, `aipass handoff`, `aipass help`, `aipass profile`. Never `drone @aipass` — that's not how it works.
`aipass init` bootstraps AIPass project in any directory, inside or outside repo. Creates registry, identity, memory, local prompt. Any folder becomes AI-powered workspace with persistent memory. Spawn adds full agent scaffolding on top.
Source: `src/aipass/cli/apps/handlers/init/bootstrap.py`
+104
View File
@@ -0,0 +1,104 @@
{
"_comment": "Per-project hook configuration for the AIPass hook engine (DPLAN-0184)",
"hooks_enabled": true,
"UserPromptSubmit": {
"identity_injector": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.identity.handle",
"matcher": ""
},
"email_notification": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.notification.email.handle",
"matcher": ""
},
"branch_prompt": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.branch_loader.handle",
"matcher": ""
},
"global_prompt": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle",
"matcher": ""
}
},
"PreToolUse": {
"tool_use_sound": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.notification.tool_sound.handle",
"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"
},
"pre_edit_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.edit_gate.handle",
"matcher": "Edit|MultiEdit|Write|NotebookEdit"
},
"git_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.git_gate.handle",
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
},
"engine_test_sound": {
"enabled": false,
"command": "python3 $AIPASS_HOME/.claude/hooks/engine_test_sound.py",
"matcher": "WebSearch"
}
},
"PostToolUse": {
"auto_fix_diagnostics": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_fix.handle",
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
"timeout": 45
},
"auto_watchdog": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle",
"matcher": "Bash"
}
},
"SubagentStop": {
"subagent_stop_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.subagent_gate.handle",
"matcher": "",
"timeout": 60
}
},
"Stop": {
"stop_sound": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.notification.stop_sound.handle",
"matcher": ""
}
},
"Notification": {
"notification_sound": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.notification.announce.handle",
"matcher": ""
}
},
"PreCompact": {
"pre_compact": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.compact.handle",
"matcher": "",
"timeout": 60
},
"pre_compact_rollover": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.rollover.handle",
"matcher": "",
"timeout": 120
}
}
}
+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)
+120
View File
@@ -0,0 +1,120 @@
{"ts": 1779087885.8874333, "event": "PreToolUse", "hook": "tool_use_sound", "exit_code": 0, "elapsed_ms": 40.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779087885.8876748, "event": "PreToolUse", "hook": "pre_edit_gate", "action": "skipped_no_match", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "value": "Read"}
{"ts": 1779087885.8881602, "event": "PreToolUse", "hook": "git_gate", "action": "skipped_no_match", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "value": "Read"}
{"ts": 1779087885.888458, "event": "PreToolUse", "hook": "engine_test_sound", "action": "skipped_no_match", "matcher": "WebSearch", "value": "Read"}
{"ts": 1779087885.9210913, "event": "PreToolUse", "hook": "BROKEN_crash_test", "exit_code": 2, "elapsed_ms": 31.2, "stdout_len": 0, "stderr_preview": "python3: can't open file '/tmp/THIS_DOES_NOT_EXIST_AT_ALL.py': [Errno 2] No such file or directory\n", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779087885.9220648, "event": "PreToolUse", "hook": "BROKEN_crash_test", "action": "crashed", "stderr": "python3: can't open file '/tmp/THIS_DOES_NOT_EXIST_AT_ALL.py': [Errno 2] No such file or directory\n"}
{"ts": 1779087885.9231257, "event": "PreToolUse", "action": "complete", "hooks_run": 0, "total_ms": 372.8}
{"ts": 1779087903.771124, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 211.9, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779087903.7721746, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 1407.3}
{"ts": 1779087908.359278, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 1317.3, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779087908.360058, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 1900.4}
{"ts": 1779087948.5783036, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 53.2, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779087948.6398153, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 60.5, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779087948.7356682, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 94.9, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779087948.8025231, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 66.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779087948.8031442, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 592.6}
{"ts": 1779087969.61676, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 83.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779087969.6175067, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 549.3}
{"ts": 1779087973.7319121, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 660.8, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779087973.7324946, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 927.3}
{"ts": 1779088321.8144712, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 44.0, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088321.8797712, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 62.3, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088321.939397, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 57.8, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088321.9993627, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 59.2, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088322.0001252, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 539.6}
{"ts": 1779088523.355975, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 69.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088523.356537, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 392.2}
{"ts": 1779088557.6554952, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 33.4, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088557.696279, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 39.6, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088557.7499194, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 52.2, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088557.7993498, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 48.2, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088557.8000984, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 995.9}
{"ts": 1779088567.4456015, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 69.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088567.4462054, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 449.2}
{"ts": 1779088570.872307, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 758.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088570.8730876, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 1024.6}
{"ts": 1779088693.5529475, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 44.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088693.6015344, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 47.8, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088693.6411777, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 38.0, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088693.6902359, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 48.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088693.6908083, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 439.1}
{"ts": 1779088702.3629355, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 85.2, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088702.3633838, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 459.4}
{"ts": 1779088706.1254911, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 649.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779088706.1261542, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 904.8}
{"ts": 1779122916.2700117, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 41.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779122916.270565, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 390.0}
{"ts": 1779122954.4108016, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 97.3, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779122954.4598262, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 47.8, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779122954.557771, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 97.1, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779122954.6079268, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 48.9, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779122954.6094744, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 672.6}
{"ts": 1779122967.6779344, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 45.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779122967.6787398, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 401.8}
{"ts": 1779123157.5541441, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 624.7, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123157.554982, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 883.3}
{"ts": 1779123163.3793867, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 33.0, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123163.4235747, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 43.1, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123163.4708867, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 46.4, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123163.5132086, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 41.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123163.5137308, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 429.9}
{"ts": 1779123186.0301352, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 50.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123186.0307028, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 342.4}
{"ts": 1779123254.4626336, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 46.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123254.5158958, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 52.1, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123254.5792346, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 62.4, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123254.6368563, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 56.7, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123254.6375203, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 481.4}
{"ts": 1779123520.2690194, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 70.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123520.269594, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 360.6}
{"ts": 1779123580.7943206, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 45.5, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123580.7948642, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 319.3}
{"ts": 1779123705.523997, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 633.8, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779123705.5245044, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 904.5}
{"ts": 1779124421.3406193, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 114.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779124421.437293, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 95.5, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779124421.537384, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 99.3, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779124421.654711, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 116.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779124421.6555922, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 1805.7}
{"ts": 1779163144.6138675, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 46.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163144.6146653, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 337.3}
{"ts": 1779163151.1238678, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 684.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163151.124623, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 933.5}
{"ts": 1779163219.5444095, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 55.7, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163219.6031945, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 57.6, "stdout_len": 130, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163219.65857, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 54.1, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163219.7402287, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 80.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163219.7411332, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 4, "total_ms": 686.5}
{"ts": 1779163230.543275, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 53.9, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163230.5454974, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 1981.7}
{"ts": 1779163260.8500037, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 56.9, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163260.9615414, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 110.3, "stdout_len": 130, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163261.0168633, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 54.4, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163261.066856, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 48.9, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163261.0678494, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 4, "total_ms": 1144.6}
{"ts": 1779163287.7181246, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 101.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163287.719954, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 2324.9}
{"ts": 1779163350.5735116, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 56.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163350.574208, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 2560.9}
{"ts": 1779163395.6311812, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 85.5, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163395.7033641, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 71.0, "stdout_len": 130, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163395.790108, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 85.7, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163395.8714736, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 80.4, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163395.8721743, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 4, "total_ms": 1668.1}
{"ts": 1779163428.9231517, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 92.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163428.9238687, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 1155.0}
{"ts": 1779163470.642621, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 82.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779163470.6436064, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 2824.2}
{"ts": 1779250863.9149616, "event": "PreToolUse", "hook": "pre_edit_gate", "action": "skipped_no_match", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "value": "Bash"}
{"ts": 1779250864.2848454, "event": "PreToolUse", "hook": "git_gate", "exit_code": 0, "elapsed_ms": 43.7, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779250864.2875721, "event": "PreToolUse", "hook": "engine_test_sound", "action": "skipped_disabled"}
{"ts": 1779250864.288863, "event": "PreToolUse", "action": "complete", "hooks_run": 0, "total_ms": 372.7}
{"ts": 1779250886.5456672, "event": "PreToolUse", "hook": "pre_edit_gate", "action": "skipped_no_match", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "value": "Bash"}
{"ts": 1779250886.9372535, "event": "PreToolUse", "hook": "git_gate", "exit_code": 0, "elapsed_ms": 35.8, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779250886.9380789, "event": "PreToolUse", "hook": "engine_test_sound", "action": "skipped_disabled"}
{"ts": 1779250886.9388382, "event": "PreToolUse", "action": "complete", "hooks_run": 0, "total_ms": 392.5}
{"ts": 1779250909.1423523, "event": "PreToolUse", "hook": "pre_edit_gate", "exit_code": 0, "elapsed_ms": 52.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779250909.1921146, "event": "PreToolUse", "hook": "git_gate", "exit_code": 0, "elapsed_ms": 48.8, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"}
{"ts": 1779250909.1928554, "event": "PreToolUse", "hook": "engine_test_sound", "action": "skipped_disabled"}
{"ts": 1779250909.1935382, "event": "PreToolUse", "action": "complete", "hooks_run": 0, "total_ms": 488.8}
+10
View File
@@ -0,0 +1,10 @@
{"ts": 1779086437.4402049, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "agent_id"]}
{"ts": 1779086460.0803485, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["user_prompt"]}
{"ts": 1779086493.5130055, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "hook_event_name"]}
{"ts": 1779086501.5574267, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "effort"]}
{"ts": 1779086534.0177336, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "effort"]}
{"ts": 1779086594.7874434, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "hook_event_name", "message"]}
{"ts": 1779086688.7642086, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "hook_event_name"]}
{"ts": 1779086728.029055, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["tool_name", "tool_input"]}
{"ts": 1779086747.247906, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "effort"]}
{"ts": 1779086820.3323202, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "hook_event_name"]}
-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)
+13 -16
View File
@@ -4,22 +4,19 @@
"cli": {
"claude": {
"hooks": [
{"script": "global_prompt_loader.py", "event": "UserPromptSubmit", "source": "repo"},
{"script": "branch_prompt_loader.py", "event": "UserPromptSubmit", "source": "repo"},
{"script": "identity_injector.py", "event": "UserPromptSubmit", "source": "repo"},
{"script": "email_notification.py", "event": "UserPromptSubmit", "source": "repo"},
{"script": "tool_use_sound.py", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "source": "repo"},
{"script": "pre_edit_gate.py", "event": "PreToolUse", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "source": "user"},
{"script": "git_gate.py", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "source": "user"},
{"script": "auto_fix_diagnostics.py", "event": "PostToolUse", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "source": "repo"},
{"script": "auto_watchdog.py", "event": "PostToolUse", "matcher": "Bash", "source": "user"},
{"script": "subagent_stop_gate.py", "event": "SubagentStop", "source": "repo"},
{"script": "stop_sound.py", "event": "Stop", "source": "repo"},
{"script": "notification_sound.py", "event": "Notification", "source": "repo"},
{"script": "pre_compact.py", "event": "PreCompact", "matcher": "manual", "source": "repo", "timeout": 60},
{"script": "pre_compact.py", "event": "PreCompact", "matcher": "auto", "source": "repo", "timeout": 60},
{"script": "pre_compact_rollover.py", "event": "PreCompact", "matcher": "manual", "source": "repo", "timeout": 120},
{"script": "pre_compact_rollover.py", "event": "PreCompact", "matcher": "auto", "source": "repo", "timeout": 120}
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:global_prompt", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop", "event": "SubagentStop"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop", "event": "Stop"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification", "event": "Notification"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "manual", "timeout": 60},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "auto", "timeout": 60},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120}
],
"env": {
"AIPASS_HOME": "{{REPO_ROOT}}",
Binary file not shown.
+41 -60
View File
@@ -1,17 +1,15 @@
# Virtual environment
.venv/
# Herald (session history — parked)
HERALD.md
# Python
__pycache__/
*.pyc
*.egg-info/
dist/
build/-
build/
.pytest_cache/
.ruff_cache/
.coverage
# ChromaDB
.chroma/
@@ -20,13 +18,10 @@ build/-
.env
.devpulse_secret.md
# API keys never leave ~/.secrets/ — gitleaks + pre-commit enforce this
# OS
.DS_Store
.vscode
# Plans (managed by flow, local to each installation)
FPLAN-*.md
DPLAN-*.md
RPLAN-*.md
TDPLAN-*.md
# AIPass runtime state (local to each installation)
AIPASS_REGISTRY.json
.trinity/
@@ -35,11 +30,19 @@ AIPASS_REGISTRY.json
ai_mail.local/
.feedback.local/
DASHBOARD.local.json
dev.local.md
STATUS.local.md
STATUS.md
dev.local.md
CLOSED_PLANS.local.json
.ai_central/
system_logs/
notepad.md
# Plans (managed by flow, local to each installation)
FPLAN-*.md
DPLAN-*.md
RPLAN-*.md
TDPLAN-*.md
# Branch local directories
logs/
@@ -49,6 +52,7 @@ tools/
docs.local/
.archive/
.backup/
.backup_system/
.recovery/
.seed/
.spawn/
@@ -56,17 +60,31 @@ docs.local/
# Module runtime JSON (config/data/log per command)
**/*_json/
# Branch-specific runtime files
src/aipass/memory/config/fragmented_memory_config.json
src/aipass/memory/config/fragmented_memory_state.json
src/aipass/memory/config/memory_bank.config.json
src/aipass/memory/config/.plans_processed.json
src/aipass/trigger/trigger_data.json
src/aipass/trigger/trigger_data.lock
src/aipass/drone/drone_command_registry.json
src/aipass/flow/CLOSED_PLANS.local.json
src/aipass/seedgo/apps/standards/aipass/pack.json
# Claude Code local state
.claude/hooks/__pycache__/
.claude/hooks/.last_diagnostics_file
.claude/hooks/probes/last_ping.jsonl
.claude/worktrees/
# @aipass citizen — now tracked. Launch (pyproject flip) still pending.
# **/.claude/settings.local.json — UNIGNORED: deny rules are system config that must travel with PRs
# Disabled files (AIPass convention: rename with (disabled) instead of delete)
*(disabled)
# Private integrations — driver layer (@api) and wrapper layer (all branches)
# Per DPLAN-0133. Contents gitignored; only scaffold README.md tracked.
src/aipass/*/apps/integrations/**
!src/aipass/*/apps/integrations/README.md
# Spawn template exceptions (template files must be tracked for public repo)
!src/aipass/spawn/templates/builder/.trinity/
!src/aipass/spawn/templates/builder/.trinity/**
@@ -93,54 +111,17 @@ docs.local/
!src/aipass/spawn/templates/builder/DASHBOARD.local.json
!src/aipass/spawn/templates/builder/STATUS.local.md
# OS
.DS_Store
# CI artifacts
windows-pytest-results/
# Test artifacts
test/
src/aipass/seedgo/apps/standards/aipass/pack.json
# Parked / one-off
HERALD.md
backup_data/
backups
backup_system
src/aipass/flow/CLOSED_PLANS.local.json
src/aipass/memory/config/fragmented_memory_config.json
src/aipass/memory/config/fragmented_memory_state.json
.vscode
src/aipass/memory/config/memory_bank.config.json
src/aipass/spawn/templates/builder/.spawn/.template_registry.json
branch_audits _only
notepad.md
src/aipass/trigger/trigger_data.json
src/aipass/memory/config/.plans_processed.json
src/aipass/drone/drone_command_registry.json
src/aipass/spawn/templates/builder/.spawn/.template_registry.json
src/aipass/memory/config/.plans_processed.json
src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/pr_plugin.cpython-312.pyc
whiteboard.md
README_ORIGINAL_DISABLED.md
backups/
backup_system/
readme_history/
src/aipass/trigger/trigger_data.lock
# STATUS files — auto-generated, contain developer session data.
# Gitignored until prax sync is fixed to produce clean public output (#298).
STATUS.md
STATUS.local.md
# Private integrations — driver layer (@api) and wrapper layer (all branches)
# Per DPLAN-0133. Contents are gitignored; only the scaffold README.md is tracked.
# Drop project-specific code into src/aipass/{branch}/apps/integrations/{project}/
# It stays local. Never appears in git.
src/aipass/*/apps/integrations/**
!src/aipass/*/apps/integrations/README.md
.coverage
branch_audits/
claude_4_7_transition_notes.md
.claude/hooks/probes/last_ping.jsonl
README_ORIGINAL_DISABLED.md
*.bak
src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/auth.cpython-312.pyc
src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/__init__.cpython-312.pyc
.backup_system
test/
+109
View File
@@ -0,0 +1,109 @@
# Changelog
All notable changes to AIPass will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project uses [Calendar Versioning](https://calver.org/) in the format
`YYYY.WNN` (year and ISO week number).
---
## [2026.W21] - 2026-05-25
First weekly release. AIPass now follows a Sunday release cadence: changes
accumulate on `dev` throughout the week and merge to `main` as a single
versioned release with notes.
### Added
- **Hook engine** — a new centralized dispatch system for all hook
execution. A thin bridge receives events from the AI provider (Claude,
Codex, etc.) and routes them through a single Python engine that reads
per-project configuration, executes the appropriate handlers, and logs
every invocation. Replaces 14 standalone shell/Python scripts with native
handler modules organized by domain: prompt injection, security
enforcement, lifecycle management, and notifications.
- **Per-project hook configuration** via `.aipass/hooks.json`. Each project
can enable, disable, or customize individual hooks without touching
provider-level settings. Previously hooks fired globally with no
per-project control.
- **Audio feedback on hook events** using Piper TTS. All 14 handlers
produce distinct spoken audio cues so operators can monitor sessions
without watching the terminal. A shared sound module
(`hooks/apps/sound.py`) provides `speak()` and `play()` with built-in
mute support. Toggle with `drone @hooks hooksound on|off` — muting
silences all 14 handlers without skipping their functional logic.
- **Hooks agent** — the 13th citizen in the AIPass registry, owning all
hook infrastructure: the engine, bridge, handlers, and configuration
schema.
- **Dashboard plugin for devpulse** — aggregates git status, session
history, and dispatch state into a single startup view. Wired into the
session startup protocol so branch managers see current state
immediately.
- **External log routing** — prax now accepts structured log entries from
any branch, not just its own modules. Hook executions, dispatches, and
agent activity all flow into the central monitoring log.
### Changed
- **Provider settings fully migrated to bridge pattern.** All hook entries
in the Claude provider configuration now call the bridge dispatcher
instead of individual scripts. Each hook produces its own system-reminder
to the model, preserving prompt injection fidelity (a single merged
bridge was found to break prompt delivery due to Claude Code's output
persistence threshold).
- **setup.sh rewritten** to install hooks via the bridge pattern. The old
version hardcoded 14 script paths; the new version writes a single bridge
call per event type and validates that the bridge module exists.
- **Documentation sweep** across `.claude/README.md`, `SECURITY.md`, the
global prompt, and branch-level docs to reflect the new hook
architecture. References to legacy `.claude/hooks/` scripts replaced with
the native handler locations.
- **`aipass init update`** now correctly preserves user-customized hook
settings during project updates instead of overwriting them.
- **Seedgo snapshot tests rebuilt** — the provider hooks snapshot fixture
and extraction logic were structurally broken (silently passing with zero
results). Both the fixture format and the test assertions have been
corrected.
- **Test suite updated for hook migration** — `test_git_gate.py` imports
from the new handler module; `test_bootstrap.py` no longer asserts that
project initialization ships standalone hook scripts (it no longer does).
### Fixed
- **Settings merge on project update** — `aipass init update` was
clobbering user hook configurations. The merge logic now layers AIPass
defaults under existing user settings.
- **Python 3.10 test collision** — a module/function name collision caused
mock patch targets to fail on Python 3.10. Test targets corrected.
- **Dead code removal** — removed an unused CLI `__main__.py` entrypoint
and cleaned up `.gitignore` entries that were masking tracked files.
- **Codecov patch threshold** lowered to 50% to reflect the project's
current coverage baseline and stop false-negative CI failures.
### Removed
- **18 standalone hook scripts** in `.claude/hooks/` disabled (renamed with
`(disabled)` suffix). Their logic now lives in native handler modules
under `src/aipass/hooks/apps/handlers/`. The old files remain on disk for
reference but are no longer executed.
- **`drone hook-sounds` plugin** disabled. Sound control moved to hooks
branch as `drone @hooks hooksound on|off` with full mute support for
all 14 handlers (the old plugin only controlled 4).
### Infrastructure
- **Provider manifest migrated to bridge pattern.** `provider_manifest.json`
now stores bridge commands (`$AIPASS_HOME/...bridges/claude.py EventType`)
instead of standalone script names. `doctor_wire.py` auto-wires bridge
entries directly — no longer copies scripts to `~/.claude/hooks/` or
generates `sys.executable` paths. Doctor checks validate commands exist in
provider settings instead of checking for script files on disk.
- **README v3** — rewritten for external users. Tighter problem/solution
framing, collapsible agent details, Gemini CLI removed (untested),
user-project perspective throughout.
---
*This is the first CHANGELOG entry. Prior work is documented in the
repository's commit history and branch session logs.*
+67 -83
View File
@@ -9,116 +9,99 @@
# AIPass
**Your AI agents remember yesterday.**
**Persistent Agent Workspace**
A local multi-agent framework where your AI assistants keep their memory between sessions, work together on the same codebase, and never ask you to re-explain context.
---
## Contents
- [The Problem](#the-problem)
- [What AIPass Does](#what-aipass-does)
- [Quick Start](#quick-start)
- [How It Works](#how-it-works)
- [The 12 Agents](#the-12-agents)
- [CLI Support](#cli-support)
- [Project Status](#project-status)
- [Requirements](#requirements)
- [Roadmap](#roadmap)
AI agents that remember, collaborate, and never start from zero.
---
## The Problem
Your AI has memory now. It remembers your name, your preferences, your last conversation. That used to be the hard part. It isn't anymore.
When the task gets complex, you become the coordinator — copying context between tools, dispatching work manually, keeping track of who's doing what. You are the glue holding your AI workflow together.
The hard part is everything that comes after. You're still one person talking to one agent in one conversation doing one thing at a time. When the task gets complex, *you* become the coordinator — copying context between tools, dispatching work manually, keeping track of who's doing what. You are the glue holding your AI workflow together, and you shouldn't have to be.
Multi-agent frameworks tried to solve this. They run agents in parallel, spin up specialists, orchestrate pipelines. But they isolate every agent in its own sandbox. Separate filesystems. Separate worktrees. Separate context. One agent can't see what another just built. Nobody picks up where a teammate left off. Nobody works on the same project at the same time. The agents don't know each other exist.
Multi-agent frameworks tried to fix this. But they isolate every agent in its own sandbox. Separate filesystems. Separate context. One agent can't see what another just built. Nobody picks up where a teammate left off.
That's not a team. That's a room full of people wearing headphones.
> *"Where else would AI presence exist except in memory? Code doesn't make AI aware — memory makes it possible."* — AIPass
What's missing isn't more agents — it's *presence*. Agents that have identity, memory, and expertise. Agents that share a workspace, communicate through their own channels, and collaborate on the same files without stepping on each other. Not isolated workers running in parallel. A persistent society with operational rules — where the system gets smarter over time because every agent remembers, every interaction builds on the last, and nobody starts from zero.
## What AIPass Does
AIPass is a local CLI framework that gives your AI agents **identity, memory, and teamwork**. Built and tested with Claude Code on Linux/WSL. Designed for terminal-native coding agents that support instruction files, hooks, and subprocess invocation.
**Start with one agent that remembers:**
Your AI reads `.trinity/` on startup and writes back what it learned before the session ends. That's the whole memory model — JSON files your AI can read and write. Next session, it picks up where it left off. No database, no API, no setup beyond one command.
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard. You work in your terminal.
```bash
pip install aipass
mkdir my-project && cd my-project
aipass init run
```
A 12-step guided setup walks you through everything: system detection, health check, profile, CLI choice, agent creation, and handoff. At the end, a new terminal window opens with your first AI agent ready to talk. The whole thing takes about 5 minutes.
A guided setup creates your project, your first agent, and opens a terminal where that agent is already running. Say "hi" — it knows who it is. Come back tomorrow — it remembers.
Your project gets its own registry, its own identity, and persistent memory. Each project is isolated — its own agents, its own rules. No cross-contamination between projects.
This is the base framework. It gives your agents the infrastructure to persist, communicate, and organize — everything else you build on top.
**Add agents when you need them:**
Here's what lands in your project:
```
my-project/
├── .aipass/ # Project config + prompts
├── .claude/ # Hooks (injected automatically)
├── src/my_project/
│ └── my-agent/
│ ├── .trinity/ # Identity + memory (3 JSON files)
│ ├── .ai_mail.local/ # Local mailbox
│ ├── apps/ # Your agent's code
│ └── README.md # Domain knowledge
├── CLAUDE.md # Project instructions
└── MY-PROJECT_REGISTRY.json
```
Everything is plain files. No daemon, no hidden state. Delete the directory and it's gone.
**Start with one agent.** Add more when you need them:
```bash
aipass init agent my-agent # Full agent: apps, mail, memory, identity
```
| What you need | Command | What you get |
|---------------|---------|-------------|
| A new project | `aipass init` | Project scaffold (registry, prompts, hooks, docs) |
| Guided setup | `aipass init run` | 12-step interactive onboarding — creates project + first agent + handoff |
| Another agent | `aipass init agent <name>` | Apps scaffold, mailbox, memory, identity — registered in project |
| A lightweight agent | `drone @spawn create <name> --template birthright` | Identity + memory only (no apps scaffold) |
**What makes this different:**
- **Agents are persistent.** They have memories and expertise that develop over time. They're not disposable workers — they're specialists who remember.
- **Everything is local.** Your data stays on your machine. Memory is JSON files. Communication is local mailbox files. No cloud dependencies, no external APIs for core operations.
- **One pattern for everything.** Every agent follows the same structure. One command (`drone @branch command`) reaches any agent. Learn it once, use it everywhere.
- **Projects are isolated by design.** Each project gets its own registry. Agents communicate within their project, not across projects.
- **The system protects itself.** Agent locks prevent double-dispatch. Git access is tier-controlled through drone. Branches don't touch each other's files. Quality standards are embedded in every workflow. Errors trigger self-healing.
- **Agents are persistent.** They remember across sessions. Expertise develops over time. Nobody starts from zero.
- **Bring your own project.** AIPass adds agent infrastructure to whatever you're building. It's a scaffold, not a product — you shape it.
- **Everything is local.** Memory is JSON files. Communication is local mailbox files. No cloud, no external APIs.
- **Shared workspace.** All agents work on the same filesystem, same project, same time. No sandboxes.
- **One command for everything.** AIPass ships with `drone`, a CLI router — `drone @agent command` reaches any agent. Learn it once, use it everywhere.
**Say "hi" tomorrow and pick up exactly where you left off.** One agent or fifteen — the memory persists.
**Runs on your existing CLI subscription.** Claude Pro/Max, Codex, or Gemini — AIPass uses the same CLI binary you already run. No extra API keys, no extra costs for core functionality.
---
## Quick Start
### Start your own project
### Your own project
```bash
pip install aipass
mkdir my-project && cd my-project
aipass init run # 12-step guided setup — creates project, first agent, opens terminal
aipass init run # Guided setup — project, first agent, terminal handoff
```
That's it. The setup creates your project, runs a health check, asks your name, creates your first AI agent, and opens a new terminal window where that agent is already running. Your agent has identity, memory, a mailbox, and knows what AIPass is. Say "hi" — it picks up where it left off. Come back tomorrow, it remembers.
Want more control? Use the individual commands:
That's it. Your agent has identity, memory, a mailbox, and access to every AIPass service — planning, quality audits, dispatch, real-time monitoring. All through `drone @branch command`.
```bash
aipass init # Just the project scaffold (no guided setup)
aipass init agent my-agent # Add another agent to your project
aipass init # Just the scaffold (no guided setup)
aipass init agent my-agent # Add another agent
aipass doctor # Check system health
```
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
Your project automatically gets access to every AIPass service — dispatch work to specialists, create plans, run quality audits, monitor agents in real-time. Agents within your project can email each other. All through `drone @branch command`.
### Explore the full framework
Clone the repo to see all 12 agents working together — the reference implementation:
Clone the repo to see all 13 agents working together — the reference implementation:
```bash
git clone https://github.com/AIOSAI/AIPass.git
cd AIPass
./setup.sh # Creates venv, installs, bootstraps 12 agents
drone systems # See all agents
./setup.sh # Creates venv, installs, bootstraps 13 agents
cd src/aipass/devpulse
claude # Talk to the orchestrator
@@ -127,57 +110,60 @@ claude # Talk to the orchestrator
```bash
# Things you can do:
aipass doctor # Check system health (15+ checks)
drone @seedgo audit aipass # Run 34 quality checks across all agents
drone @seedgo audit aipass # Run 36 quality checks across all agents
drone @flow create . "Add user auth" # Create a work plan
drone @ai_mail dispatch @agent "Subject" "Body" # Send task + wake an agent
drone @prax monitor run # Watch all agent activity in real-time
drone systems # List every agent and what it does
drone @ai_mail dispatch @agent "Sub" "Body" # Send task + wake an agent
```
---
## How It Works
**One agent:** Run `aipass init run` and in 5 minutes you have a project with an agent that reads `.trinity/` on startup and picks up where it left off. Memory files have limits — when they fill up, the memory agent automatically archives older entries into a searchable vector database (ChromaDB). Nothing is lost — it just moves from active memory to long-term recall.
**One agent:** Run `aipass init run` and in 5 minutes you have a project with an agent that reads `.trinity/` on startup and picks up where it left off. Memory starts as plain JSON files — no setup required. When they fill up, older entries automatically archive into ChromaDB for long-term search. Nothing is lost.
**A team:** When one agent isn't enough, every agent shares the same structure:
```
src/aipass/<agent>/
src/my-project/<agent>/
├── .trinity/ # Identity + memory (persists across sessions)
├── .ai_mail.local/ # Mailbox (receives tasks, sends results)
├── apps/ # Entry point → modules → handlers
└── README.md # Domain knowledge (the agent reads this on startup)
```
Identical layout everywhere. If you know one agent, you know all of them. One command reaches anyone:
Identical layout everywhere. If you know one agent, you know all of them. `drone` is the single command that routes to any agent:
```bash
drone @branch command [args] # Every agent, every task. Drone handles routing.
```
```bash
drone @seedgo audit aipass # Run quality checks on everything
drone @flow create . "Refactor auth module" # Create a work plan
drone @ai_mail dispatch @memory "Archive old sessions" "Find sessions older than 30 days"
drone @seedgo audit my-project # Run quality checks on everything
drone @flow create . "Refactor auth module" # Create a work plan
drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days"
```
**Two ways to use AIPass:**
- **Your own project:** `aipass init run` sets up a new project with your first agent. Add more agents as you need them. Your first agent is the orchestrator — it coordinates the others.
- **The full framework:** Clone the repo to work with all 12 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
- **The full framework:** Clone the repo to work with all 13 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
**AIPass ships with 12 core agents** that maintain and develop the framework — the reference implementation proving the architecture works at scale:
---
## The Reference Implementation
AIPass ships with 13 core agents that maintain and develop the framework itself — proving the architecture works at scale. You don't need any of these to use AIPass in your own project. They're here as examples and as services your project can call.
```
devpulse (orchestrator)
├── aipass — concierge + onboarding (aipass init, doctor, profile)
├── drone — command routing + @agent resolution
├── seedgo — 34 automated quality standards
├── seedgo — 36 automated quality standards
├── prax — real-time monitoring across all agents
├── ai_mail — agent-to-agent communication + task dispatch
├── flow — plan lifecycle, templates, auto-archival
├── spawn — creates new agents anywhere on your filesystem
├── hooks — hook engine, sound control, per-project config
├── memory — automatic archival, ChromaDB, semantic search
├── api — LLM access layer (OpenRouter, multi-provider)
├── trigger — event-driven automation + self-healing
@@ -186,11 +172,8 @@ devpulse (orchestrator)
These agents work on the **same filesystem, same project, same time** — no sandboxes, no worktrees. This is the pattern your projects inherit.
---
## The 12 Agents
You don't need to memorize this list. Start with `devpulse`, use `drone` to reach any agent, and learn the rest as your workflow expands.
<details>
<summary>Agent details</summary>
**You interact with one:** [**devpulse**](src/aipass/devpulse/README.md) — the orchestrator. You talk to it, it coordinates everyone else.
@@ -209,12 +192,15 @@ You don't need to memorize this list. Start with `devpulse`, use `drone` to reac
| Agent | Role |
|-------|------|
| [**seedgo**](src/aipass/seedgo/README.md) | 34 automated quality standards, enforced across all agents |
| [**seedgo**](src/aipass/seedgo/README.md) | 36 automated quality standards, enforced across all agents |
| [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards |
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — 6 template types, auto-archival, vector verification |
| [**hooks**](src/aipass/hooks/README.md) | Hook engine — per-project config, sound control, event dispatch |
| [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing |
| [**cli**](src/aipass/cli/README.md) | Terminal formatting and rich output |
</details>
---
## CLI Support
@@ -224,8 +210,7 @@ AIPass is built and tested with **Claude Code** on Linux/WSL.
| CLI | Autonomous Mode | Status |
|-----|----------------|--------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
| [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental — see [Roadmap](#roadmap) |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini -p "prompt" --approval-mode=yolo` | Experimental — see [Roadmap](#roadmap) |
| [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental |
setup.sh auto-detects which CLIs are installed and configures hooks for each.
@@ -238,10 +223,10 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each.
| Metric | Value |
|--------|-------|
| Version | 2.3.0 |
| Agents | 12 core + user-created |
| Quality standards | 34 automated checks |
| Tests | 7,600+ (across all agents) |
| PRs merged | 560+ (human-AI collaboration) |
| Agents | 13 core + user-created |
| Quality standards | 36 automated checks |
| Tests | 8,400+ (across all agents) |
| PRs merged | 600+ (human-AI collaboration) |
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
@@ -262,7 +247,6 @@ These items have partial work done and are under ongoing testing:
- **macOS support** — CI green, full test suite passing ([#360](https://github.com/AIOSAI/AIPass/issues/360))
- **Windows native** — CI green, full test suite passing
- **Codex CLI** — hooks and AGENTS.md wired, needs end-to-end testing
- **Gemini CLI** — hooks and GEMINI.md wired, needs end-to-end testing
- **Fork contributor workflow** — improved error handling for fork-based PRs ([#329](https://github.com/AIOSAI/AIPass/issues/329))
---
+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 -46
View File
@@ -499,80 +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"{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"
@@ -641,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) ---
@@ -424,7 +424,12 @@ def spawn_agent(
# Update lock with real monitor PID
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_data = {"pid": monitor_pid, "timestamp": datetime.now().isoformat(), "branch": str(branch_path)}
lock_data = {
"pid": monitor_pid,
"timestamp": datetime.now().isoformat(),
"branch": str(branch_path),
"subject": subject,
}
_write_json(lock_file, lock_data)
# Track session cycles for rotation
@@ -501,7 +501,12 @@ def wake_branch(
# Update lock with real monitor PID
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_data = {"pid": monitor_pid, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "branch": str(branch_path)}
lock_data = {
"pid": monitor_pid,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"branch": str(branch_path),
"subject": custom_message or "manual wake",
}
with open(lock_file, "w", encoding="utf-8") as f:
json.dump(lock_data, f, indent=2)
@@ -44,30 +44,20 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc
logger = logging.getLogger(__name__)
PROJECT_HOOKS = [
# Hooks are NOT distributed to projects. All hooks fire from provider
# settings (~/.claude/settings.json), installed by setup.sh. Provider hooks
# use CWD-walking patterns that work from any directory in any project.
# Hook files are shipped as reference copies only (for debugging/inspection).
HOOKS_TO_SHIP = [
"branch_prompt_loader.py",
"email_notification.py",
"identity_injector.py",
"pre_compact.py",
]
# These are shipped as reference copies but NOT wired in project settings.json
# because PreToolUse/PostToolUse/SubagentStop only fire from provider settings.
PROVIDER_ONLY_HOOKS = [
"auto_fix_diagnostics.py",
"pre_edit_gate.py",
"subagent_stop_gate.py",
]
HOOKS_TO_SHIP = PROJECT_HOOKS + PROVIDER_ONLY_HOOKS
HOOK_EVENTS: dict[str, str] = {
"pre_compact.py": "PreCompact",
"branch_prompt_loader.py": "UserPromptSubmit",
"email_notification.py": "UserPromptSubmit",
"identity_injector.py": "UserPromptSubmit",
}
def _ship_hooks(aipass_home: str, target: Path) -> list[str]:
"""Copy enforcement + injector hooks from AIPass install to target project.
@@ -143,62 +133,92 @@ def _resolve_global_prompt(name: str, aipass_home: str | None, dest: Path) -> st
return sc.with_source(sc.global_prompt_md(name), dest)
def _hook_fingerprint(hook_entry: dict) -> str:
"""Extract a comparable fingerprint from a hook entry."""
commands = []
for h in hook_entry.get("hooks", []):
cmd = h.get("command", "")
commands.append(cmd.strip())
return "|".join(sorted(commands))
def _merge_settings(existing: dict, generated: dict) -> dict:
"""Merge AIPass-generated settings with existing user settings.
Hooks are no longer distributed to projects (provider handles them).
On update, strip any previously-injected AIPass hooks from project
settings while preserving genuine user hooks.
"""
merged = {}
_aipass_hook_markers = (
".claude/hooks/",
"aipass_global_prompt.md",
"aipass_local_prompt.md",
)
existing_hooks = existing.get("hooks", {})
if existing_hooks:
cleaned_hooks: dict[str, list] = {}
for event, entries in existing_hooks.items():
user_entries = []
for entry in entries:
fp = _hook_fingerprint(entry)
if not any(marker in fp for marker in _aipass_hook_markers):
user_entries.append(entry)
if user_entries:
cleaned_hooks[event] = user_entries
if cleaned_hooks:
merged["hooks"] = cleaned_hooks
# Merge env: generated wins for AIPASS_HOME, preserve user additions
existing_env = existing.get("env", {})
generated_env = generated.get("env", {})
merged["env"] = {**existing_env, **generated_env}
# Merge permissions: union deny/ask lists
existing_perms = existing.get("permissions", {})
generated_perms = generated.get("permissions", {})
merged_perms: dict[str, list] = {}
for key in ("deny", "ask", "allow"):
existing_rules = existing_perms.get(key, [])
generated_rules = generated_perms.get(key, [])
seen: set[str] = set()
combined: list[str] = []
for rule in generated_rules + existing_rules:
if rule not in seen:
seen.add(rule)
combined.append(rule)
if combined:
merged_perms[key] = combined
if merged_perms:
merged["permissions"] = merged_perms
# Preserve any other top-level keys from existing settings
for key in existing:
if key not in merged:
merged[key] = existing[key]
return merged
def _claude_settings(aipass_home: str | None = None) -> str:
"""Generate .claude/settings.json — hooks for prompt injection at project level.
"""Generate .claude/settings.json — env and permissions only.
Only wires hooks that fire from project-level settings:
- UserPromptSubmit: global/local prompt injection + branch_prompt_loader,
email_notification, identity_injector
- PreCompact: pre_compact
Hooks are NOT wired at the project level. All AIPass hooks
(prompt injection, identity, email, pre-compact, edit gates) fire
from provider settings (~/.claude/settings.json), installed by
setup.sh. Provider hooks use CWD-walking patterns that work from
any directory in any project.
PreToolUse/PostToolUse/SubagentStop hooks are NOT wired here — they only
fire from provider settings (~/.claude/settings.json). The scripts are
still shipped as reference copies. Provider wiring is handled by setup.sh.
Project settings only contain:
- env.AIPASS_HOME (so hooks can find the AIPass installation)
- permissions.deny (basic safety rails)
Args:
aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME.
"""
_local_prompt_cmd = (
'python3 -c "'
"from pathlib import Path; "
"p=next((x/'.aipass'/'aipass_local_prompt.md' "
"for x in [Path.cwd(),*Path.cwd().parents] "
"if (x/'.aipass'/'aipass_local_prompt.md').exists()),None); "
"p and print(p.read_text(encoding='utf-8'),end='')"
'"'
)
event_hooks: dict[str, list] = {}
for hook_name, event in HOOK_EVENTS.items():
entry = {
"matcher": "",
"hooks": [{"type": "command", "command": f"python3 .claude/hooks/{hook_name}"}],
}
event_hooks.setdefault(event, []).append(entry)
prompt_hooks = [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "cat .aipass/aipass_global_prompt.md 2>/dev/null || true",
}
],
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": _local_prompt_cmd,
}
],
},
]
event_hooks["UserPromptSubmit"] = prompt_hooks + event_hooks.get("UserPromptSubmit", [])
data: dict = {"hooks": event_hooks}
data: dict = {}
data["permissions"] = {
"deny": [
@@ -482,7 +502,7 @@ def update_project(target: Path) -> dict:
else:
already_current.append(str(global_prompt_path))
# settings.json — smart merge: preserve existing AIPASS_HOME, detect if missing
# settings.json — smart merge: preserve user hooks + env, update AIPass hooks
settings_path = claude_dir / "settings.json"
if not settings_path.exists():
aipass_home = _detect_aipass_home()
@@ -491,15 +511,17 @@ def update_project(target: Path) -> dict:
else:
existing_content = settings_path.read_text(encoding="utf-8")
try:
existing_env = json.loads(existing_content).get("env", {})
existing = json.loads(existing_content)
except json.JSONDecodeError as exc:
logger.info("settings.json parse failed, rebuilding: %s", exc)
existing_env = {}
# Preserve existing AIPASS_HOME; detect and add if missing
existing = {}
existing_env = existing.get("env", {})
aipass_home = existing_env.get("AIPASS_HOME") or _detect_aipass_home()
generated = _claude_settings(aipass_home)
if existing_content != generated:
settings_path.write_text(generated, encoding="utf-8")
generated = json.loads(_claude_settings(aipass_home))
merged = _merge_settings(existing, generated)
merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
if existing != merged:
settings_path.write_text(merged_content, encoding="utf-8")
updated.append(str(settings_path))
else:
already_current.append(str(settings_path))
+25 -12
View File
@@ -268,28 +268,41 @@ def _check_provider_manifest(interactive: bool = False, fix: bool = False) -> Li
results.append(CheckResult("hooks", GLYPH_WARN, "manifest has no claude section", ""))
return results
# --- Hook scripts exist ---
# --- Hook commands wired in provider settings ---
manifest_hooks = claude_section.get("hooks", [])
hook_scripts = {h["script"] for h in manifest_hooks if "script" in h}
repo_hooks_dir = manifest_path.parent / "hooks"
user_hooks_dir = Path.home() / ".claude" / "hooks"
provider_settings_path = Path.home() / ".claude" / "settings.json"
provider_hooks: dict = {}
if provider_settings_path.exists():
try:
provider_hooks = json.loads(provider_settings_path.read_text(encoding="utf-8")).get("hooks", {})
except Exception as exc:
logger.warning("[doctor] provider settings read error (hooks): %s", exc)
missing_hooks = []
for script in sorted(hook_scripts):
source = next((h.get("source", "repo") for h in manifest_hooks if h.get("script") == script), "repo")
check_dir = user_hooks_dir if source == "user" else repo_hooks_dir
if not (check_dir / script).exists():
missing_hooks.append(script)
for hook in manifest_hooks:
command = hook.get("command", "")
event = hook.get("event", "")
if not command or not event:
continue
event_entries = provider_hooks.get(event, [])
hook_matcher = hook.get("matcher", "")
found = any(
isinstance(e, dict) and command in json.dumps(e) and e.get("matcher", "") == hook_matcher
for e in event_entries
)
if not found:
label = command.rsplit(" ", 1)[-1] if " " in command else command
missing_hooks.append(f"{event}:{label}")
if not missing_hooks:
results.append(CheckResult("hooks", GLYPH_PASS, f"{len(hook_scripts)} provider hooks present", ""))
results.append(CheckResult("hooks", GLYPH_PASS, f"{len(manifest_hooks)} provider hooks wired", ""))
else:
results.append(
CheckResult(
"hooks",
GLYPH_WARN,
f"{len(missing_hooks)} hook(s) missing: {', '.join(missing_hooks)}",
"Copy missing hooks to ~/.claude/hooks/ — see .claude/hooks/README.md",
f"{len(missing_hooks)} hook(s) missing from provider settings: {', '.join(missing_hooks)}",
"Run aipass init run or manually add bridge entries to ~/.claude/settings.json",
)
)
+18 -72
View File
@@ -12,16 +12,14 @@ doctor_wire — auto-wire provider settings
Extracted from doctor.py to keep module sizes manageable.
Provides:
- HOOK_DESCRIPTIONS / ENV_DESCRIPTIONS — human-readable hook/env purpose
- _resolve_hook_source() — locate hook scripts (repo or pip package)
- Bridge pattern — hooks wired as $AIPASS_HOME bridge commands (no script copying)
- _auto_wire_provider() — additive merge of manifest into ~/.claude/settings.json
"""
from __future__ import annotations
import json
import os
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List
@@ -53,45 +51,6 @@ ENV_DESCRIPTIONS: Dict[str, str] = {
# =============================================================================
# HOOK SOURCE RESOLUTION
# =============================================================================
def _resolve_hook_source(manifest_path: Path) -> Path | None:
"""Find where hook scripts live.
First: look for ``.claude/hooks/`` relative to the manifest path (clone/fork users).
Fallback: find the pip-installed package location via ``aipass/_hooks/``.
Returns the directory Path, or None if neither found.
"""
# Repo-local hooks (manifest lives in .claude/, hooks are in .claude/hooks/)
repo_hooks = manifest_path.parent / "hooks"
if repo_hooks.is_dir():
return repo_hooks
# Pip-installed package — _hooks directory next to top-level aipass __init__.py
try:
import importlib.resources as _resources
ref = _resources.files("aipass").joinpath("_hooks")
pkg_hooks = Path(str(ref))
if pkg_hooks.is_dir():
return pkg_hooks
except Exception as exc:
logger.info("[doctor] importlib.resources lookup failed: %s", exc)
# Manual fallback: walk up from this file to find the aipass package root
pkg_root = Path(__file__).resolve()
for _ in range(10):
pkg_root = pkg_root.parent
candidate = pkg_root / "_hooks"
if candidate.is_dir():
return candidate
if pkg_root == pkg_root.parent:
break
return None
# =============================================================================
# AUTO-WIRE
@@ -127,31 +86,17 @@ def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[s
shutil.copy2(settings_path, backup_path)
actions.append(f"Backed up settings to {backup_path.name}")
# Hooks — copy user-source scripts and add settings entries
# Hooks — add bridge entries to provider settings
manifest_hooks = claude_section.get("hooks", [])
hook_source_dir = _resolve_hook_source(manifest_path)
user_hooks_dir = Path.home() / ".claude" / "hooks"
for hook in manifest_hooks:
script = hook.get("script", "")
if not script:
command = hook.get("command", "")
event = hook.get("event", "")
if not command or not event:
continue
source_type = hook.get("source", "repo")
# Only user-source hooks get copied to ~/.claude/hooks/
if source_type == "user":
target = user_hooks_dir / script
if not target.exists() and hook_source_dir:
src_file = hook_source_dir / script
if src_file.exists():
os.makedirs(user_hooks_dir, exist_ok=True)
shutil.copy2(src_file, target)
actions.append(f"Copied hook {script} to ~/.claude/hooks/")
# Add hook entry to settings.json if not already present
if "hooks" not in settings:
settings["hooks"] = {}
event = hook.get("event", "")
if event not in settings["hooks"]:
settings["hooks"][event] = []
event_hooks = settings["hooks"][event]
@@ -159,24 +104,25 @@ def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[s
event_hooks = [event_hooks]
settings["hooks"][event] = event_hooks
already_wired = any(isinstance(h, dict) and script in json.dumps(h) for h in event_hooks)
hook_matcher = hook.get("matcher", "")
already_wired = any(
isinstance(h, dict) and command in json.dumps(h) and h.get("matcher", "") == hook_matcher
for h in event_hooks
)
if not already_wired:
if source_type == "user":
hook_path = f"~/.claude/hooks/{script}"
else:
hook_path = f".claude/hooks/{script}"
cmd_entry: Dict[str, object] = {
"type": "command",
"command": f"{sys.executable} {hook_path}",
"command": command,
}
if hook.get("timeout"):
cmd_entry["timeout"] = hook["timeout"]
wrapper: Dict[str, object] = {
"matcher": hook.get("matcher", ""),
"hooks": [cmd_entry],
}
wrapper: Dict[str, object] = {}
if hook.get("matcher"):
wrapper["matcher"] = hook["matcher"]
wrapper["hooks"] = [cmd_entry]
event_hooks.append(wrapper)
actions.append(f"Wired hook {script} -> {event}")
label = command.rsplit(" ", 1)[-1] if " " in command else command
actions.append(f"Wired hook {label} -> {event}")
# Env vars
manifest_env = claude_section.get("env", {})
@@ -313,7 +259,7 @@ def print_introspection() -> None:
console.print()
console.print("[yellow]Provides:[/yellow]")
console.print(" [dim]- HOOK_DESCRIPTIONS / ENV_DESCRIPTIONS[/dim]")
console.print(" [dim]- _resolve_hook_source() — locate hook scripts[/dim]")
console.print(" [dim]- Bridge pattern — hooks wired as $AIPASS_HOME bridge commands[/dim]")
console.print(" [dim]- _auto_wire_provider() — additive merge into settings[/dim]")
console.print()
+31 -56
View File
@@ -127,7 +127,7 @@ def test_init_project_creates_all_expected_files(tmp_path):
created_basenames = [Path(f).name for f in result["created_files"]]
for f in expected_files:
assert f.name in created_basenames or f.exists(), f"Expected {f.name} in created_files"
assert len(result["created_files"]) >= 19
assert len(result["created_files"]) >= 11
def test_init_project_return_dict_structure(tmp_path):
@@ -273,7 +273,7 @@ def test_init_project_gitignore_content(tmp_path):
def test_init_project_claude_settings_content(tmp_path):
""".claude/settings.json has valid hook configuration."""
""".claude/settings.json has env and permissions, no hooks."""
target = tmp_path / "proj"
target.mkdir()
@@ -281,12 +281,13 @@ def test_init_project_claude_settings_content(tmp_path):
settings_path = target / ".claude" / "settings.json"
data = json.loads(settings_path.read_text(encoding="utf-8"))
assert "hooks" in data
assert "UserPromptSubmit" in data["hooks"]
assert "hooks" not in data, "Hooks should not be in project settings — provider handles them"
assert "permissions" in data
assert "deny" in data["permissions"]
def test_init_project_settings_has_all_hooks(tmp_path):
""".claude/settings.json wires project-compatible hook event types."""
def test_init_project_settings_no_hooks(tmp_path):
""".claude/settings.json has no hooks — all hooks fire from provider level."""
target = tmp_path / "proj"
target.mkdir()
@@ -295,21 +296,9 @@ def test_init_project_settings_has_all_hooks(tmp_path):
settings_path = target / ".claude" / "settings.json"
data = json.loads(settings_path.read_text(encoding="utf-8"))
# UserPromptSubmit: 2 prompt injectors + 3 hook files
ups_hooks = data["hooks"]["UserPromptSubmit"]
assert len(ups_hooks) == 5, f"Expected 5 UserPromptSubmit hooks, got {len(ups_hooks)}"
assert "aipass_global_prompt.md" in ups_hooks[0]["hooks"][0]["command"]
assert "aipass_local_prompt.md" in ups_hooks[1]["hooks"][0]["command"]
assert "branch_prompt_loader.py" in ups_hooks[2]["hooks"][0]["command"]
# PreCompact fires from project level
assert len(data["hooks"]["PreCompact"]) == 1
assert "pre_compact.py" in data["hooks"]["PreCompact"][0]["hooks"][0]["command"]
# PreToolUse, PostToolUse, Stop are provider-only — NOT in project settings
assert "PreToolUse" not in data["hooks"]
assert "PostToolUse" not in data["hooks"]
assert "Stop" not in data["hooks"]
assert "hooks" not in data, "Project settings should not contain hooks"
assert "env" in data
assert "permissions" in data
def test_init_project_global_prompt_content(tmp_path):
@@ -348,7 +337,7 @@ def test_init_project_auto_creates_target_dir(tmp_path):
assert target.is_dir()
assert result["project_name"] == "NESTED"
assert len(result["created_files"]) >= 19
assert len(result["created_files"]) >= 11
def test_init_project_defaults_name_from_directory(tmp_path):
@@ -400,8 +389,8 @@ def test_init_project_skips_existing_optional_files(tmp_path):
result = init_project(target, project_name="eta")
# Only non-pre-existing files should be created (registry, hooks, package dir, etc.)
assert len(result["created_files"]) >= 11
# Only non-pre-existing files should be created (registry, package dir, etc.)
assert len(result["created_files"]) >= 5
# Verify pre-existing files were NOT overwritten
md_content = (target / "CLAUDE.md").read_text(encoding="utf-8")
@@ -574,9 +563,9 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
assert (target / ".aipass" / "aipass_global_prompt.md").exists()
assert (target / ".claude" / "settings.json").exists()
# Managed files in deleted dirs re-written (global_prompt, settings, prep + 7 hooks)
assert len(result["updated_files"]) == 10
assert len(result["already_current"]) == 3
# Managed files in deleted dirs re-written (global_prompt, settings, prep)
assert len(result["updated_files"]) == 3
assert len(result["already_current"]) >= 3
def test_update_project_skipped_files_count(tmp_path):
@@ -680,17 +669,11 @@ def test_init_project_ships_hooks(tmp_path):
pytest.skip("AIPASS_HOME not detectable in this environment")
hooks_dir = target / ".claude" / "hooks"
assert hooks_dir.is_dir()
for hook_name in [
"auto_fix_diagnostics.py",
"pre_edit_gate.py",
"subagent_stop_gate.py",
"pre_compact.py",
"branch_prompt_loader.py",
"email_notification.py",
"identity_injector.py",
]:
assert (hooks_dir / hook_name).exists(), f"Hook {hook_name} not shipped"
# Post DPLAN-0184: hooks are native handlers in src/aipass/hooks/,
# no longer shipped as script copies. Directory may or may not exist.
if hooks_dir.exists():
shipped = [f.name for f in hooks_dir.iterdir()]
assert len(shipped) == 0, f"No hook scripts should be shipped post-migration: {shipped}"
def test_init_project_hooks_not_shipped_without_aipass_home(tmp_path, monkeypatch):
@@ -733,17 +716,16 @@ def test_update_project_resyncs_hooks(tmp_path):
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
hook_file = target / ".claude" / "hooks" / "auto_fix_diagnostics.py"
hook_file.write_text("# corrupted\n", encoding="utf-8")
# Post DPLAN-0184: hooks are native handlers, not shipped as copies.
# update_project no longer resyncs hook scripts.
result = update_project(target)
assert str(hook_file) in result["updated_files"]
assert hook_file.read_text(encoding="utf-8") != "# corrupted\n"
hooks_marker = str(Path(".claude") / "hooks")
hook_paths = [f for f in result["updated_files"] if hooks_marker in f]
assert len(hook_paths) == 0, "No hook scripts should be shipped post-migration"
def test_init_project_hooks_idempotent_on_rerun(tmp_path):
"""Re-running update does not re-ship hooks when content is identical."""
"""Re-running init does not create hook script copies."""
target = tmp_path / "proj"
target.mkdir()
@@ -752,27 +734,20 @@ def test_init_project_hooks_idempotent_on_rerun(tmp_path):
if result1["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
# Use os.path.join fragment to match platform-specific separators
hooks_marker = str(Path(".claude") / "hooks")
hook_paths = [f for f in result1["created_files"] if hooks_marker in f]
assert len(hook_paths) == 7
# Update should not re-ship hooks (content identical)
result2 = update_project(target)
hook_paths_rerun = [f for f in result2["updated_files"] if hooks_marker in f]
assert len(hook_paths_rerun) == 0
assert len(hook_paths) == 0, "No hook scripts should be shipped post-migration"
def test_init_project_settings_has_all_event_types(tmp_path):
"""settings.json contains only project-compatible hook event types."""
def test_init_project_settings_has_no_hook_events(tmp_path):
"""settings.json contains no hooks — provider handles all events."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="events")
settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8"))
expected_events = {"UserPromptSubmit", "PreCompact"}
assert set(settings["hooks"].keys()) == expected_events
assert "hooks" not in settings
# ---------------------------------------------------------------------------
+35 -16
View File
@@ -11,7 +11,7 @@
import json
from unittest.mock import MagicMock, patch
import pytest
import pytest # pyright: ignore[reportMissingImports]
from aipass.aipass.apps.handlers.system_detect.system_detector import (
detect_cpu,
@@ -439,20 +439,20 @@ class TestProviderManifest:
"""All hook scripts exist → PASS for hooks."""
from aipass.aipass.apps.modules.doctor import _check_provider_manifest
hooks_dir = tmp_path / ".claude" / "hooks"
hooks_dir.mkdir(parents=True)
(hooks_dir / "hook_a.py").write_text("", encoding="utf-8")
(hooks_dir / "hook_b.py").write_text("", encoding="utf-8")
manifest = tmp_path / ".claude" / "provider_manifest.json"
manifest.parent.mkdir(parents=True)
cmd_a = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop"
cmd_b = (
"$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification"
)
manifest.write_text(
json.dumps(
{
"cli": {
"claude": {
"hooks": [
{"script": "hook_a.py", "event": "Stop", "source": "repo"},
{"script": "hook_b.py", "event": "Stop", "source": "repo"},
{"command": cmd_a, "event": "Stop"},
{"command": cmd_b, "event": "Notification"},
]
}
}
@@ -460,27 +460,41 @@ class TestProviderManifest:
),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=manifest):
provider_settings = tmp_path / ".claude" / "settings.json"
provider_settings.write_text(
json.dumps(
{
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": cmd_a}]}],
"Notification": [{"hooks": [{"type": "command", "command": cmd_b}]}],
}
}
),
encoding="utf-8",
)
with (
patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=manifest),
patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path),
):
results = _check_provider_manifest()
hooks_result = [r for r in results if r.label == "hooks"][0]
assert hooks_result.glyph == GLYPH_PASS
assert "2" in hooks_result.detail
def test_missing_hook_detected(self, tmp_path) -> None:
"""Missing hook script → WARN with script name."""
"""Missing hook command in provider settings → WARN."""
from aipass.aipass.apps.modules.doctor import _check_provider_manifest
hooks_dir = tmp_path / ".claude" / "hooks"
hooks_dir.mkdir(parents=True)
manifest = tmp_path / ".claude" / "provider_manifest.json"
manifest.parent.mkdir(parents=True)
cmd = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop"
manifest.write_text(
json.dumps(
{
"cli": {
"claude": {
"hooks": [
{"script": "missing.py", "event": "Stop", "source": "repo"},
{"command": cmd, "event": "Stop"},
]
}
}
@@ -488,11 +502,16 @@ class TestProviderManifest:
),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=manifest):
provider_settings = tmp_path / ".claude" / "settings.json"
provider_settings.write_text(json.dumps({"hooks": {}}), encoding="utf-8")
with (
patch("aipass.aipass.apps.modules.doctor._find_manifest", return_value=manifest),
patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path),
):
results = _check_provider_manifest()
hooks_result = [r for r in results if r.label == "hooks"][0]
assert hooks_result.glyph == GLYPH_WARN
assert "missing.py" in hooks_result.detail
assert "Stop" in hooks_result.detail
def test_env_vars_all_present(self, tmp_path) -> None:
"""All manifest env vars present in provider settings → PASS."""
-18
View File
@@ -1,18 +0,0 @@
# =================== AIPass ====================
# Name: __main__.py
# Description: Entry point for python -m aipass.cli
# Version: 1.0.0
# Created: 2026-03-29
# Modified: 2026-03-29
# =============================================
"""Allow running CLI as a module: python -m aipass.cli."""
import sys
from aipass.cli.apps.cli import main
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(0)
+3 -22
View File
@@ -8,10 +8,7 @@
"""Integration tests for CLI main() entry point."""
import subprocess
import sys
from io import StringIO
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -120,25 +117,9 @@ class TestMainFlow:
result = main()
assert result == 0
# =============================================================================
# __main__.py test — verify module is runnable
# =============================================================================
class TestModuleRunnable:
"""Verify python -m aipass.cli works as a subprocess."""
def test_module_runnable(self):
"""python -m aipass.cli --version runs successfully."""
result = subprocess.run(
[sys.executable, "-m", "aipass.cli", "--version"],
capture_output=True,
text=True,
timeout=30,
cwd=str(Path(__file__).resolve().parents[3]),
)
assert result.returncode == 0
# =============================================================================
# cli entry point test
# =============================================================================
def test_cli_entry_callable(self):
"""cli_entry() is the console_scripts entry point — verify it's callable."""
+30
View File
@@ -54,6 +54,36 @@
"standard": "debug_print",
"file": "tools/spot_check.py",
"reason": "Standalone CLI tool — print() is the intended output method."
},
{
"standard": "silent_catch",
"file": "tools/hook_engine_poc/engine.py",
"reason": "POC hook engine — stdlib only, no prax logger. Uses sys.stderr for error reporting."
},
{
"standard": "error_handling",
"file": "tools/hook_engine_poc/engine.py",
"reason": "POC hook engine — exception handlers write to stderr, not prax logger."
},
{
"standard": "silent_catch",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — catches test exceptions to report pass/fail, writes to stderr."
},
{
"standard": "imports",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — sys.path needed to import engine.py from same directory."
},
{
"standard": "trigger",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — .unlink() clears ephemeral JSONL log between tests, not a tracked file."
},
{
"standard": "help_text",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — usage example in docstring."
}
],
"notes": {
+75 -246
View File
@@ -1,40 +1,48 @@
# =================== AIPass ====================
# Name: test_git_gate.py
# Description: Regex coverage for git_gate.py hook (DPLAN-0163)
# Version: 1.0.0
# Description: Regex coverage for git_gate handler (DPLAN-0163, migrated DPLAN-0184)
# Version: 2.0.0
# Created: 2026-05-03
# Modified: 2026-05-03
# Modified: 2026-05-22
# =============================================
"""Tests for git_gate.py — PreToolUse hook blocking raw git/gh writes.
"""Tests for git_gate security handler.
NOTE: This test imports git_gate.py from ~/.claude/hooks/ (outside
the branch tree). This is intentional — git_gate is a user-level
hook, not a branch module, so there is no aipass package path for it.
Originally tested the standalone .claude/hooks/git_gate.py script.
Post DPLAN-0184, git_gate is a native handler at
src/aipass/hooks/apps/handlers/security/git_gate.py.
Tests now call the handler's internal functions directly.
"""
import importlib.util
import re
from pathlib import Path
import json
import pytest
_REPO_HOOK = Path(__file__).resolve().parents[4] / ".claude" / "hooks" / "git_gate.py"
_USER_HOOK = Path.home() / ".claude" / "hooks" / "git_gate.py"
HOOK_PATH = _REPO_HOOK if _REPO_HOOK.is_file() else _USER_HOOK
from aipass.hooks.apps.handlers.security.git_gate import (
_check_bash,
_check_edit,
)
_spec = importlib.util.spec_from_file_location("git_gate", HOOK_PATH)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
BLOCKED_GIT_RE = _mod.BLOCKED_GIT_RE
BLOCKED_GIT_STASH_RE = _mod.BLOCKED_GIT_STASH_RE
BLOCKED_GIT_BRANCH_RE = _mod.BLOCKED_GIT_BRANCH_RE
BLOCKED_GIT_TAG_RE = _mod.BLOCKED_GIT_TAG_RE
BLOCKED_GIT_REMOTE_RE = _mod.BLOCKED_GIT_REMOTE_RE
BLOCKED_GH_RE = _mod.BLOCKED_GH_RE
BLOCKED_GH_API_RE = _mod.BLOCKED_GH_API_RE
BLOCKED_EDIT_PATTERNS = _mod.BLOCKED_EDIT_PATTERNS
def _is_blocked(result: dict) -> bool:
"""Return True if the handler result represents a block decision."""
if result.get("exit_code", 0) == 2:
return True
stdout = result.get("stdout", "")
if not stdout:
return False
parsed = json.loads(stdout)
return parsed.get("decision") == "block"
def _bash(cmd: str) -> dict:
"""Run a Bash command through the git gate check."""
return _check_bash({"command": cmd})
def _edit(path: str, cwd: str = "/home/user/project") -> dict:
"""Run an edit path through the git gate check."""
return _check_edit({"file_path": path}, cwd)
class TestGitWriteBlocking:
@@ -61,8 +69,8 @@ class TestGitWriteBlocking:
],
)
def test_blocks_write_verbs(self, cmd):
"""Each blocked git verb triggers the regex."""
assert BLOCKED_GIT_RE.search(cmd), f"Should block: {cmd}"
"""Each blocked git verb triggers the gate."""
assert _is_blocked(_bash(cmd)), f"Should block: {cmd}"
@pytest.mark.parametrize(
"cmd",
@@ -75,7 +83,7 @@ class TestGitWriteBlocking:
)
def test_blocks_stash_destructive(self, cmd):
"""Destructive stash subcommands are blocked."""
assert BLOCKED_GIT_STASH_RE.search(cmd), f"Should block: {cmd}"
assert _is_blocked(_bash(cmd)), f"Should block: {cmd}"
@pytest.mark.parametrize(
"cmd",
@@ -84,45 +92,15 @@ class TestGitWriteBlocking:
"git branch -d old",
"git branch -m old new",
"git branch -M old new",
"git branch -c old new",
"git branch --delete old",
"git branch --move old new",
"git branch --copy old new",
"git branch --force old",
"git branch --set-upstream-to=origin/main",
"git branch --unset-upstream",
],
)
def test_blocks_branch_destructive(self, cmd):
"""Destructive branch subcommands are blocked."""
assert BLOCKED_GIT_BRANCH_RE.search(cmd), f"Should block: {cmd}"
@pytest.mark.parametrize(
"cmd",
[
"git tag -d v1.0",
"git tag --delete v1.0",
"git tag -f v1.0",
"git tag --force v1.0",
],
)
def test_blocks_tag_destructive(self, cmd):
"""Destructive tag operations are blocked."""
assert BLOCKED_GIT_TAG_RE.search(cmd), f"Should block: {cmd}"
@pytest.mark.parametrize(
"cmd",
[
"git remote add origin url",
"git remote remove origin",
"git remote rename old new",
"git remote set-url origin url",
"git remote prune origin",
],
)
def test_blocks_remote_destructive(self, cmd):
"""Destructive remote operations are blocked."""
assert BLOCKED_GIT_REMOTE_RE.search(cmd), f"Should block: {cmd}"
def test_blocks_branch_tag_remote_destructive(self, cmd):
"""Destructive branch, tag, and remote operations are blocked."""
assert _is_blocked(_bash(cmd)), f"Should block: {cmd}"
class TestLongFormFlagBypass:
@@ -135,8 +113,6 @@ class TestLongFormFlagBypass:
"git --no-pager push",
"git -c x=y commit",
"git --git-dir=/x checkout",
"git --config=core.hooksPath=/dev/null commit",
"git --no-pager -c x=y push",
"git --work-tree=/tmp commit -m 'x'",
"git -C /some/path commit",
"git --bare push origin main",
@@ -144,40 +120,28 @@ class TestLongFormFlagBypass:
)
def test_blocks_long_form_flag_bypass(self, cmd):
"""Long-form flags before the verb must not hide the write verb."""
assert BLOCKED_GIT_RE.search(cmd), f"Should block: {cmd}"
assert _is_blocked(_bash(cmd)), f"Should block: {cmd}"
class TestEscapedQuoteBypass:
"""DPLAN-0163 Finding 2: escaped quotes must not break quote-stripping.
The gate strips quoted strings before scanning, so commit messages
containing git verbs don't trigger false positives. Escaped quotes
inside those strings must not break the stripping.
"""
"""Escaped quotes must not break quote-stripping."""
@pytest.mark.parametrize(
"cmd,should_block",
[
('echo "git commit inside quotes"', False),
("echo 'git push inside single quotes'", False),
('echo "msg \\"escaped\\" inner"', False),
("echo 'msg \\'escaped\\' inner'", False),
('drone @git pr "fix: git commit msg"', False),
('git commit -m "msg \\"escaped\\""', True),
('git commit -m "msg"', True),
],
)
def test_escaped_quote_stripping(self, cmd, should_block):
"""Escaped quotes inside strings must not leak verb matches."""
scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd)
scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan)
matched = bool(BLOCKED_GIT_RE.search(scan))
assert matched == should_block, (
f"{'Should block' if should_block else 'Should allow'}: {cmd}\n After strip: {scan}"
)
"""Quoted strings containing git verbs must not trigger false positives."""
assert _is_blocked(_bash(cmd)) == should_block, f"{'Should block' if should_block else 'Should allow'}: {cmd}"
class TestReadOnlyAllowed:
"""Read-only git commands must not be blocked."""
class TestReadOnlyBlocked:
"""New handler blocks ALL raw git — read-only included. Use drone."""
@pytest.mark.parametrize(
"cmd",
@@ -185,40 +149,15 @@ class TestReadOnlyAllowed:
"git status",
"git log --oneline",
"git diff",
"git diff --staged",
"git show HEAD",
"git fetch",
"git fetch origin",
"git ls-files",
"git log --graph --all",
"git stash list",
"git stash show",
"git rev-parse HEAD",
"git describe --tags",
"git remote -v",
"git blame file.py",
"git shortlog -sn",
"git branch",
"git branch -r",
"git branch -a",
"git branch --list",
"git branch -v",
"git branch --show-current",
"git branch --contains abc123",
"git tag",
"git tag -l",
"git tag --list",
"git remote",
"git remote show origin",
"git remote -v",
],
)
def test_allows_read_only(self, cmd):
"""Read-only git subcommands must pass through."""
assert not BLOCKED_GIT_RE.search(cmd), f"Should allow: {cmd}"
assert not BLOCKED_GIT_STASH_RE.search(cmd), f"Should allow: {cmd}"
assert not BLOCKED_GIT_BRANCH_RE.search(cmd), f"Should allow: {cmd}"
assert not BLOCKED_GIT_TAG_RE.search(cmd), f"Should allow: {cmd}"
assert not BLOCKED_GIT_REMOTE_RE.search(cmd), f"Should allow: {cmd}"
def test_blocks_read_only_raw_git(self, cmd):
"""Read-only raw git is also blocked — use drone instead."""
assert _is_blocked(_bash(cmd)), f"Should block raw git (use drone): {cmd}"
class TestDroneNotBlocked:
@@ -237,11 +176,11 @@ class TestDroneNotBlocked:
)
def test_allows_drone(self, cmd):
"""Drone-wrapped git ops are not raw git — must pass."""
assert not BLOCKED_GIT_RE.search(cmd), f"Should allow: {cmd}"
assert not _is_blocked(_bash(cmd)), f"Should allow: {cmd}"
class TestGhBlocking:
"""gh write subcommands blocked, read-only allowed."""
"""gh write subcommands blocked, gh api allowed."""
@pytest.mark.parametrize(
"cmd",
@@ -253,31 +192,21 @@ class TestGhBlocking:
"gh issue close 5",
"gh release create v1",
"gh repo create x",
"gh api repos/x/pulls -X POST",
],
)
def test_blocks_gh_writes(self, cmd):
"""State-changing gh subcommands are blocked."""
blocked = BLOCKED_GH_RE.search(cmd) or BLOCKED_GH_API_RE.search(cmd)
assert blocked, f"Should block: {cmd}"
assert _is_blocked(_bash(cmd)), f"Should block: {cmd}"
@pytest.mark.parametrize(
"cmd",
[
"gh pr list",
"gh pr view 42",
"gh pr status",
"gh pr diff 42",
"gh pr checks 42",
"gh issue list",
"gh issue view 5",
"gh issue status",
"gh api repos/x/pulls",
],
)
def test_allows_gh_reads(self, cmd):
"""Read-only gh subcommands must pass through."""
assert not BLOCKED_GH_RE.search(cmd), f"Should allow: {cmd}"
assert not BLOCKED_GH_API_RE.search(cmd), f"Should allow: {cmd}"
def test_allows_gh_api(self, cmd):
"""gh api is allowed for read access."""
assert not _is_blocked(_bash(cmd)), f"Should allow: {cmd}"
class TestEditBlocking:
@@ -295,8 +224,7 @@ class TestEditBlocking:
)
def test_blocks_protected_paths(self, path):
"""Enforcement-layer files are protected from edits."""
matched = any(p.search(path) for p in BLOCKED_EDIT_PATTERNS)
assert matched, f"Should block edit: {path}"
assert _is_blocked(_edit(path)), f"Should block edit: {path}"
@pytest.mark.parametrize(
"path",
@@ -309,128 +237,29 @@ class TestEditBlocking:
)
def test_allows_normal_paths(self, path):
"""Normal project files are not blocked."""
matched = any(p.search(path) for p in BLOCKED_EDIT_PATTERNS)
assert not matched, f"Should allow edit: {path}"
assert not _is_blocked(_edit(path)), f"Should allow edit: {path}"
_PY3 = "python3"
class TestBypassDetection:
"""Issue #561: bypass vectors that circumvent regex scanning."""
@pytest.fixture(autouse=True)
def _setup(self, tmp_path):
"""Create test script files for bypass detection tests."""
self.gate = HOOK_PATH
self.cwd = str(tmp_path)
evil_sh = tmp_path / "evil.sh"
evil_sh.write_text("#!/bin/bash\ngit commit -m hack\ngit push\n", encoding="utf-8")
self.evil_sh = str(evil_sh)
evil_py = tmp_path / "evil.py"
evil_py.write_text("import subprocess\nsubprocess.run(['git','push'])\n", encoding="utf-8")
self.evil_py = str(evil_py)
safe_sh = tmp_path / "safe.sh"
safe_sh.write_text("#!/bin/bash\necho hello\nls -la\n", encoding="utf-8")
self.safe_sh = str(safe_sh)
def _run(self, cmd):
"""Pipe a command to git_gate.py and return exit code."""
import json
import subprocess
import sys
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}, "cwd": self.cwd})
result = subprocess.run(
[sys.executable, str(self.gate)],
input=payload,
capture_output=True,
text=True,
timeout=5,
def test_trusted_editors_bypass(self):
"""Trusted branches (devpulse, seedgo) can edit protected paths."""
result = _check_edit(
{"file_path": "/home/user/.claude/hooks/test.py"},
"/home/user/src/aipass/devpulse/something",
)
return result.returncode
assert not _is_blocked(result), "devpulse should be trusted editor"
class TestNonGitAllowed:
"""Normal commands without git/gh pass through."""
@pytest.mark.parametrize(
"cmd",
[
f"{_PY3} -c \"import subprocess; subprocess.run(['git','commit'])\"",
f"{_PY3} -c \"import os; os.system('git push')\"",
f"{_PY3} -c \"from subprocess import Popen; Popen(['gh','pr','create'])\"",
f"{_PY3} -c \"from os import popen; popen('git merge')\"",
f"{_PY3} -c \"from os import system; system('git push')\"",
"echo hello world",
"ls -la",
"cat file.txt",
"grep -r pattern .",
],
)
def test_blocks_subprocess_bypass(self, cmd):
"""Subprocess wrapping git/gh is detected and blocked."""
assert self._run(cmd) == 2, f"Should block subprocess bypass: {cmd}"
@pytest.mark.parametrize(
"cmd",
[
"/usr/bin/git commit -m test",
"/usr/local/bin/git push",
"/snap/bin/gh pr create --title x",
],
)
def test_blocks_full_path_bypass(self, cmd):
"""Full binary paths to git/gh are detected and blocked."""
assert self._run(cmd) == 2, f"Should block full path: {cmd}"
def test_blocks_bash_script_with_git(self):
"""Script containing git commands is blocked when run via bash."""
assert self._run(f"bash {self.evil_sh}") == 2
def test_blocks_sh_script_with_git(self):
"""Script containing git commands is blocked when run via sh."""
assert self._run(f"sh {self.evil_sh}") == 2
def test_blocks_source_script_with_git(self):
"""Script containing git commands is blocked when sourced."""
assert self._run(f"source {self.evil_sh}") == 2
def test_blocks_dot_source_script_with_git(self):
"""Script containing git commands is blocked via dot-source."""
assert self._run(f". {self.evil_sh}") == 2
def test_blocks_python_script_with_git(self):
"""Python script containing subprocess+git is blocked."""
assert self._run(f"{_PY3} {self.evil_py}") == 2
def test_blocks_cat_pipe_bash(self):
"""Piping script contents to bash is detected and blocked."""
assert self._run(f"cat {self.evil_sh} | bash") == 2
def test_blocks_bash_stdin_redirect(self):
"""Stdin redirect to bash is detected and blocked."""
assert self._run(f"bash < {self.evil_sh}") == 2
def test_blocks_xargs_git(self):
"""Using xargs to construct git commands is blocked."""
assert self._run("echo commit | xargs git") == 2
def test_blocks_variable_expansion(self):
"""Variable assignment of git followed by execution is blocked."""
assert self._run("cmd=git; $cmd commit -m test") == 2
def test_allows_safe_script(self):
"""Script without git commands passes through."""
assert self._run(f"bash {self.safe_sh}") == 0
def test_allows_subprocess_no_git(self):
"""Subprocess calls without git/gh are allowed."""
assert self._run(f"{_PY3} -c \"import subprocess; subprocess.run(['ls'])\"") == 0
def test_allows_read_only_full_path(self):
"""Read-only git via full path is allowed."""
assert self._run("/usr/bin/git log --oneline") == 0
def test_allows_nonexistent_script(self):
"""Nonexistent script passes through gracefully."""
assert self._run("bash /tmp/nonexistent_xyz.sh") == 0
def test_allows_echo(self):
"""Normal commands without git are allowed."""
assert self._run("echo hello world") == 0
# =============================================
def test_allows_normal_commands(self, cmd):
"""Commands without git/gh are allowed."""
assert not _is_blocked(_bash(cmd)), f"Should allow: {cmd}"
+10 -9
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
@@ -71,7 +72,6 @@ drone list # List registered custom command shortcuts
drone remove <name> # Remove a custom command shortcut
# Utilities
drone hook-sounds on|off # Toggle hook notification sounds
drone --version # Show version (v1.1.0)
drone --help # Show usage information
```
@@ -173,6 +173,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/
@@ -182,8 +183,8 @@ drone/
│ │ ├── merge_plugin.py # PR merge (--merge) + local sync
│ │ ├── sync_plugin.py # Smart sync (fetch, divergence detect, rebase)
│ │ └── fix_plugin.py # Auto-fix stuck rebase / detached HEAD
│ └── hook_sounds/
│ └── hook_sounds_plugin.py # Toggle notification sounds on/off
│ └── hook_sounds/ # DISABLED — moved to hooks branch (drone @hooks hooksound on/off)
│ └── hook_sounds_plugin.py.disabled
├── docs/ # Public documentation
├── docs.local/ # Investigation reports and policies
└── tests/ # 704 tests across 21 test files
@@ -192,7 +193,7 @@ drone/
### Routing Flow
1. **CLI input** → `drone.py:main()`
2. **Built-in commands** checked first: `systems`, `scan`, `activate`, `list`, `remove`, `hook-sounds`
2. **Built-in commands** checked first: `systems`, `scan`, `activate`, `list`, `remove`
3. **`@target` routing** → branch resolution via `AIPASS_REGISTRY.json` → subprocess dispatch
4. **Module fallback** → if branch not found but is a registered module, routes internally
5. **Bare module names** → auto-discovered from `apps/modules/*.py`, routed via `importlib`
@@ -216,7 +217,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 +231,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
@@ -275,9 +276,9 @@ Auth-gated operations for system administration. `auth.py` walks CWD for `.trini
| `sync_plugin` | `smart-sync` | Fetch + detect divergence + rebase |
| `fix_plugin` | `fix` | Auto-fix stuck rebase / detached HEAD |
### hook_sounds
### hook_sounds (DISABLED)
Simple toggle for hook notification sounds. Creates/removes `/tmp/aipass-hooks-muted` flag file.
Moved to hooks branch as `drone @hooks hooksound on/off`. Plugin file renamed to `.disabled`.
---
@@ -323,7 +324,7 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches
| Git operations | `test_git_module.py`, `test_system_pr.py`, `test_devpulse_plugins.py`, `test_git_access.py` | ~150 |
| Handlers | `test_executor.py`, `test_registry_handler.py`, `test_discovery.py` | ~99 |
| Infrastructure | `test_generic_adapter.py`, `test_module_registry.py`, `test_config.py` | ~66 |
| Features | `test_commands.py`, `test_scan.py`, `test_hook_sounds.py`, `test_json_handler.py` | ~125 |
| Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py` | ~125 |
| Standards | `test_cli_routing.py`, `test_contracts.py`, `test_error_resilience.py`, `test_init_provisioning.py` | ~21 |
Run tests: `cd src/aipass/drone && python -m pytest tests/ -q`
-9
View File
@@ -85,7 +85,6 @@ def show_help() -> None:
table.add_row("activate @target", "Register all commands from a branch")
table.add_row("list", "List registered custom commands")
table.add_row("remove <name>", "Remove a custom command")
table.add_row("hook-sounds on|off", "Toggle hook notification sounds")
table.add_row("--help", "Show this help")
table.add_row("--version", "Show version")
@@ -551,14 +550,6 @@ def main() -> int:
if command == "list":
return _handle_list()
# hook-sounds — toggle hook notification sounds
if command == "hook-sounds":
from aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin import handle_command as hs_handle
cmd = args[1] if len(args) > 1 else None
hs_handle(cmd)
return 0
# remove — remove a custom command by name
if command == "remove":
if len(args) < 2:
+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")
@@ -265,36 +265,6 @@ class TestMainList:
mock_list.assert_called_once()
class TestMainHookSounds:
"""drone hook-sounds command."""
_HS = "aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin.handle_command"
def test_hook_sounds_on(self) -> None:
"""hook-sounds on delegates to plugin."""
from aipass.drone.apps.drone import main
with (
patch.object(sys, "argv", ["drone", "hook-sounds", "on"]),
patch(self._HS) as mock_hs,
):
result = main()
assert result == 0
mock_hs.assert_called_once_with("on")
def test_hook_sounds_no_arg(self) -> None:
"""hook-sounds with no arg passes None."""
from aipass.drone.apps.drone import main
with (
patch.object(sys, "argv", ["drone", "hook-sounds"]),
patch(self._HS) as mock_hs,
):
result = main()
assert result == 0
mock_hs.assert_called_once_with(None)
class TestMainRemove:
"""drone remove command."""
+3
View File
@@ -0,0 +1,3 @@
# Branch Prompt
AI context for `HOOKS`. The `aipass_local_prompt.md` file is injected every turn, telling the AI who you are and how to work in your branch.
@@ -0,0 +1,107 @@
# HOOKS -- Branch Prompt
Injected every turn. Breadcrumbs only -- details in README, --help, .trinity/, STATUS.local.md.
## Identity
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
- 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
## 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
```
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
```
apps/
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
- **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
- 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
- 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.
+5
View File
@@ -0,0 +1,5 @@
# Claude Code Settings
Claude Code configuration for `HOOKS`.
Contains `settings.local.json` with permission rules. Most branches are denied raw git commands and must use `drone @git` instead.
+15
View File
@@ -0,0 +1,15 @@
__pycache__/
*.pyc
*.pyo
.env
.venv/
*.egg-info/
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
dist/
build/
*.log
*.tmp
*.swp
+5
View File
@@ -0,0 +1,5 @@
# Standards Bypass
Seedgo audit bypass config for `HOOKS`.
When an audit flags a false positive that doesn't apply to your architecture, add a bypass entry in `bypass.json` with a reason explaining why it's justified.
+398
View File
@@ -0,0 +1,398 @@
{
"metadata": {
"version": "1.1.0",
"created": "2026-05-18",
"updated": "2026-05-21",
"description": "Standards bypass configuration for this branch"
},
"bypass": [
{
"file": "apps/modules/engine.py",
"standard": "json_structure",
"reason": "Engine uses JSONL diagnostic logging, not branch json_handler — different purpose"
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "handlers",
"reason": "Bridges import engine module by design — that is their entire purpose"
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "json_structure",
"reason": "Thin entry point, no JSON operations to log"
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "dead_code",
"reason": "Bridge called externally by provider settings subprocess — no internal import"
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "architecture",
"reason": "Bridge importing engine module is its architectural purpose"
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "imports",
"reason": "Bridge imports engine module by design — sole purpose"
},
{
"file": "apps/hooks.py",
"standard": "unused_function",
"reason": "print_introspection() called by drone's discovery system, not internal code"
},
{
"file": "apps/handlers/config/loader.py",
"standard": "json_structure",
"reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this"
},
{
"file": "apps/handlers/config/diagnostics.py",
"standard": "json_structure",
"reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage"
},
{
"file": "apps/handlers/notification/tool_sound.py",
"standard": "json_structure",
"reason": "Sound handler — no JSON operations, plays WAV files"
},
{
"file": "tests/conftest.py",
"standard": "architecture",
"reason": "Test fixtures live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_tool_sound.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_tool_sound.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_tool_sound.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_tool_sound.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "tests/test_engine.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_engine.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_engine.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_engine.py",
"standard": "help_text",
"reason": "Test data contains command references as part of test fixtures, not user-facing help"
},
{
"file": "tests/test_engine.py",
"standard": "json_handler",
"reason": "Hooks branch does not use json_handler — has its own JSONL logging"
},
{
"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": {
"removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead."
}
}
+81
View File
@@ -0,0 +1,81 @@
[← Back to AIPass](../../../README.md)
# Hooks
> Hook infrastructure for AIPass. Single engine dispatches all hooks across platforms (Claude, Codex, Gemini) with per-project config, full logging, and testability. The 13th citizen.
Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (.aipass/hooks.json), dispatches matching handlers, and logs everything to prax + JSONL.
## Start here
| You want to | Read |
|---|---|
| What's happening right now | [STATUS.local.md](STATUS.local.md) |
| Identity, memory, session history | [`.trinity/`](.trinity/) |
| Hook engine design | `DPLAN-0184` |
| Per-project config | `.aipass/hooks.json` |
## Commands
| Command | 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 hooksound` | Show current sound mute status |
| `drone @hooks hooksound off` | Mute all hook sounds |
| `drone @hooks hooksound on` | Unmute all hook sounds |
| `drone @hooks --help` | Full help reference |
| `drone @hooks --version` | Version info |
## Architecture
```
src/aipass/hooks/
├── .trinity/ # Identity & memory
├── apps/
│ ├── hooks.py # Entry point (drone @hooks)
│ ├── sound.py # Shared sound utilities (speak, play, mute)
│ ├── modules/
│ │ ├── engine.py # Core dispatch — routes events to handlers
│ │ └── hooksound.py # Sound control (drone @hooks hooksound on/off)
│ ├── handlers/
│ │ ├── bridges/ # One per provider (thin normalization)
│ │ │ └── claude.py # Claude Code bridge
│ │ ├── prompt/ # Prompt injection hooks
│ │ ├── security/ # Enforcement hooks (edit gate, git gate)
│ │ ├── lifecycle/ # Session hooks (compact, stop, subagent)
│ │ └── notification/ # Sound/alert hooks
│ └── config/ # hooks.json validation
├── logs/
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
├── tests/ # 236 tests
└── STATUS.local.md
```
## How It Works
1. Provider settings have 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 one
5. First hook returning `{"decision": "block"}` with exit code 2 = bail (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 Points
### Depends On
| Branch | What for |
|---|---|
| prax | Logging (system_logger for prax monitor visibility) |
### Provides To
All branches via hook dispatch. Every Claude Code session routes through the engine.
*Last Updated: 2026-05-22*
---
[← Back to AIPass](../../../README.md)
+8
View File
@@ -0,0 +1,8 @@
# Apps
Application layer for `HOOKS`.
- `hooks.py` — Entry point. Auto-discovers and routes commands to modules.
- `modules/` — Business logic and orchestration. One module per command.
- `handlers/` — Implementation details. Called by modules, never by CLI directly.
- `plugins/` — Scheduled tasks and extensions.
+2
View File
@@ -0,0 +1,2 @@
# HOOKS apps package
from . import handlers # noqa: F401
+5
View File
@@ -0,0 +1,5 @@
# Handlers
Implementation details for `HOOKS`.
Handlers do the actual work. They are called by modules, never directly by the CLI. Keep business logic in modules, implementation in handlers.
@@ -0,0 +1,88 @@
"""HOOKS handlers package - Security protected."""
import inspect
from pathlib import Path
MY_BRANCH = "aipass.hooks"
def _find_real_caller():
"""Walk the stack to find the actual file that triggered this import.
Skips this file, importlib internals, and frozen modules.
Returns tuple: (file_path, import_line) or (None, None).
"""
stack = inspect.stack()
this_file = str(Path(__file__).resolve())
for frame_info in stack:
filename = frame_info.filename
if this_file in str(Path(filename).resolve()):
continue
if filename.startswith("<") or "importlib" in filename:
continue
import_line = None
if frame_info.code_context:
import_line = frame_info.code_context[0].strip()
return str(Path(filename).resolve()), import_line
return None, None
def _extract_branch_name(filepath: str) -> str:
"""Extract branch name from a file path."""
parts = Path(filepath).parts
for i, part in enumerate(parts):
if part == "aipass":
if i + 1 < len(parts):
return parts[i + 1]
return "unknown"
def _guard_branch_access():
"""Block cross-branch handler imports.
Only code from within the 'hooks' branch can import these handlers.
External branches must use aipass.hooks.apps.modules instead.
"""
caller_file, import_line = _find_real_caller()
if caller_file is None:
stack = inspect.stack()
for frame in stack:
if frame.filename in ("<string>", "<stdin>"):
return
return
branch_path = "/" + MY_BRANCH.replace(".", "/") + "/"
if branch_path in caller_file.replace("\\", "/"):
return
caller_branch = _extract_branch_name(caller_file)
caller_filename = Path(caller_file).name
blocked_import = import_line if import_line else "unknown"
raise ImportError(
f"\n{'=' * 60}\n"
f"ACCESS DENIED: Cross-branch handler import blocked\n"
f"{'=' * 60}\n"
f" Caller branch: {caller_branch}\n"
f" Caller file: {caller_filename}\n"
f" Blocked: {blocked_import}\n"
f"\n"
f" Handlers are internal to their branch.\n"
f" Use the module API instead:\n"
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
f"\n"
f" For full standards guide:\n"
f" drone @seedgo handlers\n"
f"{'=' * 60}"
)
# Run guard at import time
_guard_branch_access()
@@ -0,0 +1,61 @@
# =================== AIPass ====================
# Name: claude.py
# Version: 1.0.0
# Description: Claude Code bridge — entry point for provider hook settings
# Branch: hooks
# Layer: apps/handlers/bridges
# Created: 2026-05-18
# Modified: 2026-05-18
# =============================================
"""
Claude Code bridge.
Thin entry point called from ~/.claude/settings.json hook entries.
Normalizes Claude Code's stdin/stdout format and calls the engine.
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
from aipass.hooks.apps.modules.engine import dispatch, find_project_config
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> or claude.py <EventType:hook_name>\n")
sys.exit(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():
stdin_data = sys.stdin.read()
config = find_project_config()
if config is 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)
if __name__ == "__main__":
main()
@@ -0,0 +1,37 @@
# =================== AIPass ====================
# Name: diagnostics.py
# Version: 1.0.0
# Description: JSONL diagnostic logging for hook engine
# Branch: hooks
# Layer: apps/handlers/config
# Created: 2026-05-19
# Modified: 2026-05-19
# =============================================
"""JSONL diagnostic logging — appends structured entries for hook activity."""
import json
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent.parent
LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl"
def log_entry(entry: dict) -> None:
"""Append a JSONL log entry for detailed diagnostics."""
try:
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
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("[HOOKS] log write failed: %s", exc)
def tail_log(count: int = 20) -> list[str]:
"""Return the last N lines from the engine log."""
if not LOG_FILE.exists():
return []
lines = LOG_FILE.read_text().strip().split("\n")
return lines[-count:]
@@ -0,0 +1,38 @@
# =================== AIPass ====================
# Name: loader.py
# Version: 1.0.0
# Description: Hook config loader — finds and parses .aipass/hooks.json
# Branch: hooks
# Layer: apps/handlers/config
# Created: 2026-05-19
# Modified: 2026-05-19
# =============================================
"""Loads per-project hook configuration from .aipass/hooks.json."""
import json
import os
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
AIPASS_HOME = os.environ.get("AIPASS_HOME", "")
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("[HOOKS] bad config %s: %s", config, exc)
return None
search = search.parent
return None
@@ -0,0 +1,368 @@
# =================== 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
from pathlib import Path
from aipass.hooks.apps.sound import speak
from aipass.prax.apps.modules.logger import system_logger as logger
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,51 @@
# =================== 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
from aipass.hooks.apps.sound import speak
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,146 @@
# =================== 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
from pathlib import Path
from aipass.hooks.apps.sound import speak
from aipass.prax.apps.modules.logger import system_logger as logger
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,38 @@
#!/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
from pathlib import Path
from aipass.hooks.apps.sound import speak
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
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,30 +46,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", {})
if schema_version.startswith("2"):
reasons = []
has_v2_limits = any(k in limits for k in ("max_sessions", "max_key_learnings", "max_observations"))
if has_v2_limits:
reasons: list[str] = []
max_sessions = limits.get("max_sessions")
if max_sessions is not None:
sessions = data.get("sessions", [])
@@ -90,7 +88,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:
@@ -98,28 +95,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"],
@@ -130,44 +122,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,33 @@
# =================== 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
from pathlib import Path
from aipass.hooks.apps.sound import speak
AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", ""))
SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds"
SOUND_FILE = SOUNDS_DIR / "mixkit-clear-announce-tones-2861.wav"
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,103 @@
# =================== 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
from pathlib import Path
from aipass.hooks.apps.sound import speak
from aipass.prax.apps.modules.logger import system_logger as logger
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,36 @@
# =================== 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
from pathlib import Path
from aipass.hooks.apps.sound import speak
AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", ""))
SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds"
SOUND_FILE = SOUNDS_DIR / "mixkit-achievement-bell-600.wav"
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,30 @@
# =================== AIPass ====================
# Name: tool_sound.py
# Version: 1.1.0
# Description: Announces hook name via Piper TTS on tool use
# Branch: hooks
# Layer: apps/handlers/notification
# Created: 2026-05-19
# Modified: 2026-05-19
# =============================================
"""Announces hook name via Piper TTS when the AI uses tools (PreToolUse event)."""
from aipass.hooks.apps.sound import speak
def handle(hook_data: dict) -> dict:
"""Announce hook name for matching tool use events.
Args:
hook_data: Parsed hook event dict from engine (tool_name, etc.)
Returns:
Result dict with stdout (empty) and exit_code.
"""
tool_name = hook_data.get("tool_name", "")
if not tool_name:
return {"stdout": "", "exit_code": 0}
speak(f"tool sound: {tool_name}")
return {"stdout": "", "exit_code": 0}
@@ -0,0 +1,61 @@
# =================== 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."""
from pathlib import Path
from aipass.hooks.apps.sound import speak
from aipass.prax.apps.modules.logger import system_logger as logger
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,38 @@
# =================== 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
from pathlib import Path
from aipass.hooks.apps.sound import speak
from aipass.prax.apps.modules.logger import system_logger as logger
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,89 @@
# =================== 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
from pathlib import Path
from aipass.hooks.apps.sound import speak
from aipass.prax.apps.modules.logger import system_logger as logger
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,136 @@
# =================== 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
from pathlib import Path
from aipass.hooks.apps.sound import speak
from aipass.prax.apps.modules.logger import system_logger as logger
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,124 @@
# =================== 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
from pathlib import Path
from aipass.hooks.apps.sound import speak
from aipass.prax.apps.modules.logger import system_logger as logger
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,180 @@
# =================== 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
from pathlib import Path
from aipass.hooks.apps.sound import speak
from aipass.prax.apps.modules.logger import system_logger as logger
_ALLOW = {"stdout": "", "exit_code": 0}
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
+157
View File
@@ -0,0 +1,157 @@
# =================== AIPass ====================
# Name: hooks.py
# Version: 1.1.0
# Description: Hook infrastructure — drone entry point
# Branch: hooks
# Layer: apps
# Created: 2026-05-18
# Modified: 2026-05-19
# =============================================
"""
HOOKS Branch - Main Orchestrator
Auto-discovery architecture:
- Scans modules/ directory for .py files with handle_command()
- Routes commands to discovered modules automatically
- No manual imports or routing needed
"""
import os
import sys
import importlib
from pathlib import Path
from typing import Any
os.environ.setdefault("AIPASS_BRANCH_NAME", "hooks")
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402
from aipass.cli.apps.modules import err_console # noqa: E402
CONSOLE = err_console
# =============================================================================
# MODULE DISCOVERY
# =============================================================================
MODULES_DIR = Path(__file__).parent / "modules"
def discover_modules() -> list[Any]:
"""Auto-discover modules in modules/ directory."""
modules = []
if not MODULES_DIR.exists():
return modules
for file_path in sorted(MODULES_DIR.glob("*.py")):
if file_path.name.startswith("_"):
continue
module_names = [
f"aipass.hooks.apps.modules.{file_path.stem}",
f"apps.modules.{file_path.stem}",
]
loaded = False
for module_name in module_names:
try:
module = importlib.import_module(module_name)
if hasattr(module, "handle_command"):
modules.append(module)
loaded = True
break
except (ImportError, ModuleNotFoundError) as e:
logger.info("[HOOKS] Module %s not found: %s", module_name, e)
continue
except Exception as e:
logger.error("[HOOKS] Failed to load module %s: %s", module_name, e)
loaded = True
break
if not loaded:
logger.error("[HOOKS] Could not import module %s", file_path.stem)
return modules
def print_introspection():
"""Print branch introspection — discovered modules and capabilities."""
modules = discover_modules()
CONSOLE.print("[bold cyan]HOOKS[/bold cyan] — Hook Infrastructure for AIPass")
CONSOLE.print(f" Modules discovered: {len(modules)}")
for module in modules:
name = module.__name__.split(".")[-1]
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
CONSOLE.print(f" {name:20} {desc}")
def print_help():
"""Print CLI help — usage instructions and available commands."""
modules = discover_modules()
CONSOLE.print("[bold cyan]HOOKS[/bold cyan] — Usage")
CONSOLE.print()
CONSOLE.print(" drone @hooks <command> [args...]")
CONSOLE.print()
CONSOLE.print("[bold]COMMANDS:[/bold]")
for module in modules:
name = module.__name__.split(".")[-1]
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
CONSOLE.print(f" {name:20} {desc}")
CONSOLE.print()
CONSOLE.print("[bold]BRIDGES:[/bold]")
CONSOLE.print(" claude Claude Code bridge (provider settings entry point)")
CONSOLE.print()
CONSOLE.print("[bold]FLAGS:[/bold]")
CONSOLE.print(" --help, -h Show this help message")
CONSOLE.print(" --version, -V Show version")
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
"""Route command to appropriate module."""
for module in modules:
try:
if module.handle_command(command, args):
return True
except Exception as e:
logger.error("[HOOKS] Module %s error: %s", module.__name__, e)
return False
# =============================================================================
# MAIN ENTRY POINT
# =============================================================================
def handle_command(command: str, args: list) -> bool:
"""Entry point for drone routing."""
modules = discover_modules()
if command in ["--help", "-h", "help"]:
print_help()
return True
if command in ["--version", "-V"]:
CONSOLE.print("hooks 1.1.0")
return True
return route_command(command, args, modules)
def main() -> int:
"""Main entry point — routes commands or shows help."""
args = sys.argv[1:]
if not args:
print_introspection()
return 0
if handle_command(args[0], args[1:]):
return 0
CONSOLE.print(f"Unknown command: {args[0]}. Try: drone @hooks --help")
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,64 @@
# apps/integrations/
Private integration space for `HOOKS`.
**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline.
## What goes here
**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain.
```
apps/integrations/
└── {project}/
├── wrapper.py # How this branch uses the driver
├── config.json # Optional — local config
└── tests/ # Private tests colocated
```
Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here.
## What does NOT go here
- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer).
- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that.
- **Drone plugins** — use `apps/plugins/` for those.
- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo.
## Architecture
The full design is in DPLAN-0133 (private integrations architecture). Three layers:
1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name.
2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects.
3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call <contract>`) — advertise the extension points without naming specifics. Fork-safe.
## Usage
```python
# Your public code (committed, in apps/modules/ or apps/handlers/)
from aipass.api import memory_backend
results = memory_backend.query("when did we ship watchdog?")
# memory_backend is a generic contract. In your local setup it routes to whatever
# driver you registered in @api/apps/integrations/. In a fresh clone with nothing
# registered, it returns NotConfigured gracefully.
```
```python
# Your private wrapper (in this folder, gitignored)
# apps/integrations/{project}/wrapper.py
from aipass.api import memory_backend
def domain_specific_query(context):
"""Branch-specific query pattern for domain needs."""
hint = build_query_from_context(context)
return memory_backend.query(hint, top_k=5, filter={"kind": "decision"})
```
The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code.
---
See DPLAN-0133 for the full design rationale.
+5
View File
@@ -0,0 +1,5 @@
# Modules
Business logic for `HOOKS`. One module per command.
Modules orchestrate work by calling handlers. They are the public API of the branch — drone routes commands here.
+276
View File
@@ -0,0 +1,276 @@
# =================== AIPass ====================
# Name: engine.py
# Version: 1.1.0
# Description: Hook engine — unified dispatcher for all hook events
# Branch: hooks
# Layer: apps/modules
# Created: 2026-05-18
# Modified: 2026-05-19
# =============================================
"""Hook engine — dispatches hook events to handlers, logs via prax + JSONL."""
import importlib
import json
import os
import subprocess
import time
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import err_console
from aipass.hooks.apps.handlers.config.loader import find_project_config
from aipass.hooks.apps.handlers.config.diagnostics import log_entry as _log, tail_log
CONSOLE = err_console
BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent
def _run_hook(hook_cmd: str, stdin_data: str, timeout_s: int = 30) -> 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=timeout_s,
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("[HOOKS] timeout after %ds: %s", timeout_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("[HOOKS] exec error: %s: %s", hook_cmd, exc)
return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)}
def _run_handler(handler_path: str, hook_data: dict) -> dict:
"""Call a handler function directly (no subprocess). Module imports handler."""
start = time.monotonic()
try:
module_path, func_name = handler_path.rsplit(".", 1)
module = importlib.import_module(module_path)
handler_func = getattr(module, func_name)
result = handler_func(hook_data)
elapsed_ms = (time.monotonic() - start) * 1000
return {
"exit_code": result.get("exit_code", 0),
"stdout": result.get("stdout", ""),
"stderr": "",
"elapsed_ms": round(elapsed_ms, 1),
}
except Exception as exc:
elapsed_ms = (time.monotonic() - start) * 1000
logger.error("[HOOKS] handler error %s: %s", handler_path, 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. Empty matcher = always match."""
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("[HOOKS] 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 = ""
parsed = {}
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("[HOOKS] 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("[HOOKS] %s.%s skipped (disabled)", event_type, hook_name)
_log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_disabled"})
continue
handler = hook_def.get("handler", "")
command = hook_def.get("command", "")
matcher = hook_def.get("matcher", "")
if not handler and 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
if handler:
result = _run_handler(handler, parsed)
else:
hook_timeout = hook_def.get("timeout", 30)
result = _run_hook(command, stdin_data, timeout_s=hook_timeout)
logger.info(
"[HOOKS] %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()),
}
)
# Exit code 2: crash vs intentional block
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):
logger.info("[HOOKS] %s.%s exit=2 stdout not JSON, treating as crash", event_type, hook_name)
if is_intentional_block:
total_ms = (time.monotonic() - total_start) * 1000
logger.warning("[HOOKS] %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(
"[HOOKS] %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("[HOOKS] %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)
# =============================================================================
# MODULE INTERFACE (drone @hooks routing)
# =============================================================================
def print_introspection():
"""Print module structure — connected handlers."""
CONSOLE.print("[bold cyan]engine[/bold cyan] Module")
CONSOLE.print(" Connected Handlers:")
handlers_root = BRANCH_ROOT / "apps" / "handlers"
for category_dir in sorted(handlers_root.iterdir()):
if not category_dir.is_dir() or category_dir.name.startswith("_"):
continue
handler_files = [f.name for f in sorted(category_dir.glob("*.py")) if not f.name.startswith("_")]
if handler_files:
CONSOLE.print(f" handlers/{category_dir.name}/ — {', '.join(handler_files)}")
def handle_command(command: str, args: list) -> bool:
"""Route engine commands from drone @hooks."""
if not args and command in ("engine", ""):
print_introspection()
return True
if command in ("--help", "-h", "help"):
CONSOLE.print("[bold cyan]engine[/bold cyan] — Hook dispatch engine")
CONSOLE.print()
CONSOLE.print(" drone @hooks status Show hook config for current project")
CONSOLE.print(" drone @hooks log Tail recent hook activity")
return True
if command == "status":
config = find_project_config()
if config is None:
CONSOLE.print("No .aipass/hooks.json found for current project")
else:
enabled = config.get("hooks_enabled", True)
CONSOLE.print(f"Hooks enabled: {enabled}")
for event_type, hooks in config.items():
if event_type.startswith("_") or event_type == "hooks_enabled":
continue
if isinstance(hooks, dict):
active = sum(1 for h in hooks.values() if isinstance(h, dict) and h.get("enabled", True))
total = sum(1 for h in hooks.values() if isinstance(h, dict))
CONSOLE.print(f" {event_type}: {active}/{total} hooks active")
return True
if command == "log":
lines = tail_log(20)
if not lines:
CONSOLE.print("No engine log found")
else:
for line in lines:
CONSOLE.print(line)
return True
return False
@@ -0,0 +1,59 @@
# =================== AIPass ====================
# Name: hooksound.py
# Version: 1.0.0
# Description: Hook sound control — mute/unmute all hook audio
# Branch: hooks
# Layer: apps/modules
# Created: 2026-05-22
# Modified: 2026-05-22
# =============================================
"""Hook sound control — mute and unmute all hook audio via drone @hooks hooksound."""
from aipass.cli.apps.modules import err_console
from aipass.hooks.apps.sound import MUTE_FLAG, is_muted
CONSOLE = err_console
def print_introspection():
"""Print module structure for drone routing."""
status = "MUTED" if is_muted() else "ACTIVE"
CONSOLE.print(f"[bold cyan]hooksound[/bold cyan] — Hook sound control ({status})")
def handle_command(command: str, args: list) -> bool:
"""Route hooksound commands from drone @hooks."""
if command == "hooksound":
sub = args[0] if args else None
if sub in ("--help", "-h", "help"):
CONSOLE.print("[bold cyan]hooksound[/bold cyan] — Mute/unmute all hook audio")
CONSOLE.print()
CONSOLE.print(" drone @hooks hooksound Show current status")
CONSOLE.print(" drone @hooks hooksound on Unmute all hook sounds")
CONSOLE.print(" drone @hooks hooksound off Mute all hook sounds")
return True
if sub == "off":
MUTE_FLAG.touch()
CONSOLE.print("[yellow]Hook sounds MUTED[/yellow]")
return True
if sub == "on":
if MUTE_FLAG.exists():
MUTE_FLAG.unlink()
CONSOLE.print("[green]Hook sounds ACTIVE[/green]")
return True
if sub is None:
if is_muted():
CONSOLE.print("[yellow]Hook sounds: MUTED[/yellow]")
CONSOLE.print(f" Flag: {MUTE_FLAG}")
CONSOLE.print(" Run: drone @hooks hooksound on")
else:
CONSOLE.print("[green]Hook sounds: ACTIVE[/green]")
CONSOLE.print(" Run: drone @hooks hooksound off")
return True
return False
+5
View File
@@ -0,0 +1,5 @@
# Plugins
Scheduled tasks and extensions for `HOOKS`.
Plugins are standalone units of work that can be scheduled via the daemon. Each plugin handles one specific recurring task.
+85
View File
@@ -0,0 +1,85 @@
# =================== AIPass ====================
# Name: sound.py
# Version: 1.0.0
# Description: Shared sound utilities — Piper TTS and WAV playback with mute support
# Branch: hooks
# Layer: apps
# Created: 2026-05-22
# Modified: 2026-05-22
# =============================================
"""Shared sound functions for hook handlers. Checks mute flag before playing."""
import subprocess
import tempfile
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import err_console
CONSOLE = err_console
MUTE_FLAG = Path("/tmp/aipass-hooks-muted")
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
def print_introspection():
"""Print module structure for drone routing."""
CONSOLE.print("[bold cyan]sound[/bold cyan] — Shared sound utilities (speak, play, mute)")
def is_muted() -> bool:
"""Check whether hook sounds are currently muted."""
return MUTE_FLAG.exists()
def speak(text: str) -> None:
"""Generate speech via Piper TTS and play it. Skips if muted."""
if is_muted():
return
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()
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] speak: piper timed out")
except OSError as exc:
logger.info("[HOOKS] speak: playback error: %s", exc)
def play(sound_path: Path) -> None:
"""Play a WAV file via aplay. Skips if muted."""
if is_muted():
return
if not sound_path.exists():
logger.info("[HOOKS] play: 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] play: playback error: %s", exc)
+3
View File
@@ -0,0 +1,3 @@
# Docs
Documentation files for the `HOOKS` branch.
+17
View File
@@ -0,0 +1,17 @@
[pytest]
# Test discovery paths
testpaths = tests
# Test file patterns
python_files = test_*.py
python_functions = test_*
python_classes = Test*
# Command-line options (always applied)
addopts = -v --tb=short --strict-markers -ra
# Test markers (for categorizing tests)
markers =
unit: Unit tests
integration: Integration tests
slow: Tests that take significant time
@@ -0,0 +1,3 @@
# Project-specific Python packages only.
# These are installed into the AIPass venv: pip install -r requirements.project.txt
# Framework packages (drone, prax, chromadb, rich, etc.) are already available via AIPass.
+5
View File
@@ -0,0 +1,5 @@
# Templates
Branch-specific templates for `HOOKS`.
Any templates this branch provides to the system or uses internally. Examples: plan templates (flow), trinity templates (memory), test templates (seedgo).
+6
View File
@@ -0,0 +1,6 @@
# Tests
Pytest unit tests for `HOOKS`.
- `conftest.py` — Shared fixtures (temp dirs, mocks, sample data).
- `test_*.py` — Test files. Standard tests cover JSON handler, CLI routing, and error resilience. Custom tests cover branch-specific domain logic.
+1
View File
@@ -0,0 +1 @@
# Tests package for hooks
+80
View File
@@ -0,0 +1,80 @@
# =================== AIPass ====================
# Name: conftest.py
# Version: 1.0.0
# Description: Shared pytest fixtures for hooks tests
# Branch: hooks
# Layer: tests
# Created: 2026-05-18
# Modified: 2026-05-18
# =============================================
"""Shared pytest fixtures for hooks tests."""
import json
import shutil
import tempfile
from pathlib import Path
from typing import Generator
from unittest.mock import patch
import pytest
@pytest.fixture
def temp_test_dir() -> Generator[Path, None, None]:
"""Creates temporary directory for testing, cleans up after."""
test_dir = Path(tempfile.mkdtemp())
yield test_dir
if test_dir.exists():
shutil.rmtree(test_dir)
@pytest.fixture
def sample_hooks_config() -> dict:
"""Minimal hooks.json config for testing."""
return {
"hooks_enabled": True,
"UserPromptSubmit": {
"test_hook": {
"enabled": True,
"command": "echo 'test output'",
"matcher": "",
}
},
"PreToolUse": {
"matcher_hook": {
"enabled": True,
"command": "echo 'matched'",
"matcher": "Edit|Write",
},
"disabled_hook": {
"enabled": False,
"command": "echo 'should not fire'",
"matcher": "",
},
},
}
@pytest.fixture
def hooks_config_file(temp_test_dir: Path, sample_hooks_config: dict) -> Path:
"""Creates a .aipass/hooks.json in temp dir."""
config_dir = temp_test_dir / ".aipass"
config_dir.mkdir()
config_file = config_dir / "hooks.json"
config_file.write_text(json.dumps(sample_hooks_config), encoding="utf-8")
return config_file
@pytest.fixture
def mock_logger():
"""Mock the prax system logger."""
with patch("aipass.hooks.apps.modules.engine.logger") as mock:
yield mock
@pytest.fixture
def mock_subprocess():
"""Mock subprocess.run for hook execution tests."""
with patch("aipass.hooks.apps.modules.engine.subprocess.run") as mock:
yield mock

Some files were not shown because too many files have changed in this diff Show More