feat: DPLAN-0184 Phase 2 complete + DPLAN-0186 post-migration sweep — 14 native handlers, bridge routing, docs/setup alignment
This commit is contained in:
@@ -79,7 +79,7 @@ Read-only awareness (all branches):
|
||||
|
||||
All write operations (commit, push, merge, checkout) restricted to devpulse via tier-based access. Dispatched agents build code, run tests — devpulse reviews diff, commits.
|
||||
|
||||
Drone runs git via Python subprocess, bypasses settings.json deny rules by design — drone is the gate. `git_gate.py` PreToolUse hook enforces mechanically — applies to ALL sessions including dispatched agents. bypassPermissions does not skip hooks.
|
||||
Drone runs git via Python subprocess, bypasses settings.json deny rules by design — drone is the gate. Git gate (PreToolUse hook) enforces mechanically — applies to ALL sessions including dispatched agents. bypassPermissions does not skip hooks.
|
||||
|
||||
Local files = source of truth. Edit file → state on disk IS reality.
|
||||
|
||||
|
||||
+104
-126
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -1,215 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: engine.py
|
||||
# Description: Hook Engine — unified dispatcher for all hook events
|
||||
# Version: 0.3.0
|
||||
# Created: 2026-05-17
|
||||
# Modified: 2026-05-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Hook Engine — DPLAN-0184 Phase 1.
|
||||
|
||||
Single entry point for all hook events. Reads per-project config (.aipass/hooks.json),
|
||||
dispatches to registered hooks, logs everything via prax logger.
|
||||
|
||||
Called from provider settings (must use venv python for prax imports):
|
||||
$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/.claude/hooks/engine.py <EventType>
|
||||
|
||||
Stdin/stdout contract matches the platform's hook interface.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
AIPASS_HOME = os.environ.get("AIPASS_HOME", "")
|
||||
LOG_DIR = Path(AIPASS_HOME) / ".claude" / "hooks" if AIPASS_HOME else Path(__file__).parent
|
||||
LOG_FILE = LOG_DIR / "engine.jsonl"
|
||||
|
||||
|
||||
def _find_project_config() -> dict | None:
|
||||
"""Walk up from CWD looking for .aipass/hooks.json."""
|
||||
search = Path.cwd()
|
||||
home = Path.home()
|
||||
while search != home and search.parent != search:
|
||||
config = search / ".aipass" / "hooks.json"
|
||||
if config.exists():
|
||||
try:
|
||||
raw = config.read_text(encoding="utf-8")
|
||||
if AIPASS_HOME:
|
||||
raw = raw.replace("$AIPASS_HOME", AIPASS_HOME)
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.error("[hook_engine] bad config %s: %s", config, exc)
|
||||
return None
|
||||
search = search.parent
|
||||
return None
|
||||
|
||||
|
||||
def _log(entry: dict) -> None:
|
||||
"""Append a JSONL log entry for detailed diagnostics."""
|
||||
try:
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
except OSError as exc:
|
||||
logger.error("[hook_engine] log write failed: %s", exc)
|
||||
|
||||
|
||||
def _run_hook(hook_cmd: str, stdin_data: str) -> dict:
|
||||
"""Run a single hook subprocess, capture output and timing."""
|
||||
env = os.environ.copy()
|
||||
start = time.monotonic()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
hook_cmd,
|
||||
shell=True,
|
||||
input=stdin_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"elapsed_ms": round(elapsed_ms, 1),
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
logger.error("[hook_engine] timeout after 30s: %s", hook_cmd)
|
||||
return {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": round(elapsed_ms, 1)}
|
||||
except OSError as exc:
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
logger.error("[hook_engine] exec error: %s: %s", hook_cmd, exc)
|
||||
return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)}
|
||||
|
||||
|
||||
def _matches(matcher: str, value: str) -> bool:
|
||||
"""Check if a hook's matcher string matches the given value."""
|
||||
if not matcher:
|
||||
return True
|
||||
return value in matcher.split("|")
|
||||
|
||||
|
||||
def dispatch(event_type: str, stdin_data: str, config: dict) -> str:
|
||||
"""Core dispatch — run hooks for event, return merged stdout."""
|
||||
if not config.get("hooks_enabled", True):
|
||||
logger.info("[hook_engine] all hooks disabled")
|
||||
_log({"ts": time.time(), "event": event_type, "action": "all_hooks_disabled"})
|
||||
return ""
|
||||
|
||||
event_hooks = config.get(event_type, {})
|
||||
if not event_hooks:
|
||||
_log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"})
|
||||
return ""
|
||||
|
||||
match_value = ""
|
||||
try:
|
||||
parsed = json.loads(stdin_data) if stdin_data.strip() else {}
|
||||
match_value = parsed.get("tool_name", "") or parsed.get("compact_type", "") or parsed.get("type", "")
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("[hook_engine] stdin parse error: %s", exc)
|
||||
|
||||
outputs = []
|
||||
total_start = time.monotonic()
|
||||
|
||||
for hook_name, hook_def in event_hooks.items():
|
||||
if not hook_def.get("enabled", True):
|
||||
logger.info("[hook_engine] %s.%s skipped (disabled)", event_type, hook_name)
|
||||
_log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_disabled"})
|
||||
continue
|
||||
|
||||
command = hook_def.get("command", "")
|
||||
matcher = hook_def.get("matcher", "")
|
||||
if not command:
|
||||
continue
|
||||
|
||||
if matcher and not _matches(matcher, match_value):
|
||||
_log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_no_match",
|
||||
"matcher": matcher, "value": match_value})
|
||||
continue
|
||||
|
||||
result = _run_hook(command, stdin_data)
|
||||
|
||||
logger.info(
|
||||
"[hook_engine] %s.%s exit=%d out=%db %dms",
|
||||
event_type, hook_name, result["exit_code"],
|
||||
len(result["stdout"]), result["elapsed_ms"],
|
||||
)
|
||||
_log({
|
||||
"ts": time.time(),
|
||||
"event": event_type,
|
||||
"hook": hook_name,
|
||||
"exit_code": result["exit_code"],
|
||||
"elapsed_ms": result["elapsed_ms"],
|
||||
"stdout_len": len(result["stdout"]),
|
||||
"stderr_preview": result["stderr"][:200] if result["stderr"] else "",
|
||||
"cwd": str(Path.cwd()),
|
||||
})
|
||||
|
||||
if result["exit_code"] == 2:
|
||||
is_intentional_block = False
|
||||
try:
|
||||
decision = json.loads(result["stdout"]) if result["stdout"].strip() else {}
|
||||
is_intentional_block = decision.get("decision") == "block"
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
if is_intentional_block:
|
||||
total_ms = (time.monotonic() - total_start) * 1000
|
||||
logger.warning(
|
||||
"[hook_engine] %s BLOCKED by %s (%dms)",
|
||||
event_type, hook_name, total_ms,
|
||||
)
|
||||
_log({"ts": time.time(), "event": event_type, "action": "blocked",
|
||||
"hook": hook_name, "total_ms": round(total_ms, 1)})
|
||||
return result["stdout"]
|
||||
|
||||
logger.error(
|
||||
"[hook_engine] %s.%s CRASHED exit=2: %s",
|
||||
event_type, hook_name, result["stderr"][:200],
|
||||
)
|
||||
_log({"ts": time.time(), "event": event_type, "hook": hook_name,
|
||||
"action": "crashed", "stderr": result["stderr"][:200]})
|
||||
|
||||
if result["stdout"]:
|
||||
outputs.append(result["stdout"])
|
||||
|
||||
total_ms = (time.monotonic() - total_start) * 1000
|
||||
logger.info("[hook_engine] %s complete: %d hooks %dms", event_type, len(outputs), total_ms)
|
||||
_log({"ts": time.time(), "event": event_type, "action": "complete",
|
||||
"hooks_run": len(outputs), "total_ms": round(total_ms, 1)})
|
||||
|
||||
return "\n".join(outputs)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point — receive event type, dispatch, output result."""
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write("Usage: engine.py <EventType>\n")
|
||||
sys.exit(1)
|
||||
|
||||
event_type = sys.argv[1]
|
||||
|
||||
stdin_data = ""
|
||||
if not sys.stdin.isatty():
|
||||
stdin_data = sys.stdin.read()
|
||||
|
||||
config = _find_project_config()
|
||||
if config is None:
|
||||
config = {"hooks_enabled": True}
|
||||
|
||||
output = dispatch(event_type, stdin_data, config)
|
||||
if output:
|
||||
sys.stdout.write(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,54 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: engine_test_hook.py
|
||||
# Description: Test hook for engine POC — proves engine dispatch works
|
||||
# Version: 0.1.0
|
||||
# Created: 2026-05-17
|
||||
# Modified: 2026-05-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Test hook that proves the engine dispatched correctly.
|
||||
Writes a timestamped entry to engine_test.log and outputs a system reminder.
|
||||
Safe — no side effects beyond logging.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
LOG_FILE = Path(os.environ.get("AIPASS_HOME", "")) / ".claude" / "hooks" / "engine_test.log"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Log proof of execution and output a system reminder."""
|
||||
stdin_data = ""
|
||||
if not sys.stdin.isatty():
|
||||
stdin_data = sys.stdin.read()
|
||||
|
||||
event_info = {}
|
||||
try:
|
||||
if stdin_data.strip():
|
||||
event_info = json.loads(stdin_data)
|
||||
except json.JSONDecodeError as exc:
|
||||
sys.stderr.write(f"engine_test_hook: parse error: {exc}\n")
|
||||
|
||||
entry = {
|
||||
"ts": time.time(),
|
||||
"hook": "engine_test_hook",
|
||||
"cwd": str(Path.cwd()),
|
||||
"event_keys": list(event_info.keys())[:5],
|
||||
}
|
||||
|
||||
try:
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
except OSError as exc:
|
||||
sys.stderr.write(f"engine_test_hook: log failed: {exc}\n")
|
||||
|
||||
sys.stdout.write("ENGINE_TEST: Hook engine dispatched successfully\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,40 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: engine_test_sound.py
|
||||
# Description: Test sound hook for engine POC — plays distinct beep
|
||||
# Version: 0.2.0
|
||||
# Created: 2026-05-17
|
||||
# Modified: 2026-05-18
|
||||
# =============================================
|
||||
|
||||
"""Plays a distinct A5 beep to prove the engine dispatched this hook."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SOUNDS_DIR = Path(__file__).parent.parent / "sounds"
|
||||
DEFAULT_SOUND = SOUNDS_DIR / "engine_test_beep.wav"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Play the test beep sound. Accepts optional sound file arg."""
|
||||
sound = DEFAULT_SOUND
|
||||
if len(sys.argv) > 1:
|
||||
candidate = Path(sys.argv[1])
|
||||
if candidate.exists():
|
||||
sound = candidate
|
||||
|
||||
if not sound.exists():
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
["aplay", "-q", str(sound)],
|
||||
timeout=5, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Version: 1.0.0
|
||||
"""Tool Use Hook — Plays key press sound when AI uses tools."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
SOUNDS_DIR = Path(__file__).parent.parent / "sounds"
|
||||
SOUND_FILE = SOUNDS_DIR / "mixkit-atm-cash-machine-key-press-2841.wav"
|
||||
|
||||
SOUND_TOOLS = ["Bash", "Edit", "MultiEdit", "Write", "Read", "Grep", "Glob"]
|
||||
|
||||
|
||||
def play_sound() -> None:
|
||||
if not SOUND_FILE.exists():
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["aplay", "-q", str(SOUND_FILE)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
hook_data = json.loads(sys.stdin.read())
|
||||
if hook_data.get("hook_event_name") == "PreToolUse":
|
||||
if hook_data.get("tool_name", "") in SOUND_TOOLS:
|
||||
play_sound()
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("PreToolUse", "provider", __file__, main)
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -499,82 +499,82 @@ fi
|
||||
# --- Install Claude Code hooks ---
|
||||
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
|
||||
|
||||
if [ -d "$SCRIPT_DIR/.claude/hooks" ]; then
|
||||
# Determine python command for non-Claude provider hooks (Gemini, etc).
|
||||
# Claude hooks use bridge pattern with $AIPASS_HOME env var — no HOOK_PYTHON needed.
|
||||
# Linux: keep "python3" — distros ship 3.10+ and hooks import nothing
|
||||
# version-specific beyond that.
|
||||
# macOS: stock /usr/bin/python3 is 3.9.6 on macOS 12 and cannot parse
|
||||
# scripts that use PEP 604 union syntax (`X | None`). Use the venv python.
|
||||
# Windows: existing venv-python behavior.
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
HOOK_PYTHON="$SCRIPT_DIR/.venv/Scripts/python.exe"
|
||||
elif [ "$IS_MACOS" -eq 1 ]; then
|
||||
HOOK_PYTHON="$SCRIPT_DIR/.venv/bin/python3"
|
||||
else
|
||||
HOOK_PYTHON="python3"
|
||||
fi
|
||||
|
||||
if [ -f "$SCRIPT_DIR/src/aipass/hooks/apps/handlers/bridges/claude.py" ]; then
|
||||
echo "Installing Claude Code hooks ..."
|
||||
mkdir -p "$HOME/.claude"
|
||||
|
||||
# Determine python command for hooks.
|
||||
# Linux: keep "python3" — distros ship 3.10+ and hooks import nothing
|
||||
# version-specific beyond that. Leaving this path unchanged per Linux stability.
|
||||
# macOS: stock /usr/bin/python3 is 3.9.6 on macOS 12 and cannot parse hook
|
||||
# scripts that use PEP 604 union syntax (`X | None`). Point at the venv
|
||||
# python, which setup just built with a 3.10+ interpreter.
|
||||
# Windows: existing venv-python behavior.
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
HOOK_PYTHON="$SCRIPT_DIR/.venv/Scripts/python.exe"
|
||||
elif [ "$IS_MACOS" -eq 1 ]; then
|
||||
HOOK_PYTHON="$SCRIPT_DIR/.venv/bin/python3"
|
||||
else
|
||||
HOOK_PYTHON="python3"
|
||||
fi
|
||||
|
||||
"$PYTHON" - "$SCRIPT_DIR" "$CLAUDE_SETTINGS" "$HOOK_PYTHON" << 'PYEOF'
|
||||
"$PYTHON" - "$SCRIPT_DIR" "$CLAUDE_SETTINGS" << 'PYEOF'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
repo_root = sys.argv[1]
|
||||
settings_path = Path(sys.argv[2])
|
||||
hook_python = sys.argv[3]
|
||||
hooks_dir = f"{repo_root}/.claude/hooks"
|
||||
|
||||
# Bridge entry point — all hooks route through the engine via this bridge.
|
||||
# Uses $AIPASS_HOME env var (injected into settings.env below) so the
|
||||
# settings file stays relocatable.
|
||||
bridge = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py"
|
||||
|
||||
# Load existing settings or start fresh
|
||||
if settings_path.exists():
|
||||
settings = json.loads(settings_path.read_text())
|
||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
settings = {}
|
||||
|
||||
# Build hooks config with absolute paths
|
||||
# Build hooks config — bridge pattern
|
||||
# UserPromptSubmit: 4 separate entries (EventType:hook_name) to avoid output merging
|
||||
# PreToolUse, PostToolUse, SubagentStop, Stop, Notification: single aggregate entries
|
||||
# PreCompact: 2 hooks x 2 matchers (manual + auto) = 4 entries
|
||||
settings["hooks"] = {
|
||||
"UserPromptSubmit": [
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/global_prompt_loader.py"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/branch_prompt_loader.py"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/identity_injector.py"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/email_notification.py"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:global_prompt"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:branch_prompt"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:identity_injector"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:email_notification"}]},
|
||||
],
|
||||
"PreToolUse": [
|
||||
{"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
|
||||
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/tool_use_sound.py"}]},
|
||||
{"matcher": "Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_edit_gate.py"}]},
|
||||
{"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/git_gate.py"}]},
|
||||
"hooks": [{"type": "command", "command": f"{bridge} PreToolUse"}]},
|
||||
],
|
||||
"PostToolUse": [
|
||||
{"matcher": "Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_fix_diagnostics.py"}]},
|
||||
{"matcher": "Bash",
|
||||
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_watchdog.py"}]},
|
||||
],
|
||||
"Stop": [
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/stop_sound.py"}]},
|
||||
],
|
||||
"Notification": [
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/notification_sound.py"}]},
|
||||
{"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [{"type": "command", "command": f"{bridge} PostToolUse"}]},
|
||||
],
|
||||
"SubagentStop": [
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/subagent_stop_gate.py"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} SubagentStop"}]},
|
||||
],
|
||||
"Stop": [
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} Stop"}]},
|
||||
],
|
||||
"Notification": [
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} Notification"}]},
|
||||
],
|
||||
"PreCompact": [
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact.py", "timeout": 60}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact.py", "timeout": 60}]},
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact_rollover.py", "timeout": 120}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact_rollover.py", "timeout": 120}]},
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact", "timeout": 60}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact", "timeout": 60}]},
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact_rollover", "timeout": 120}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact_rollover", "timeout": 120}]},
|
||||
],
|
||||
}
|
||||
|
||||
# Inject AIPASS_HOME into env block so dispatched agents find AIPass
|
||||
import os
|
||||
env_block = settings.get("env", {})
|
||||
env_block["AIPASS_HOME"] = repo_root
|
||||
env_block["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1"
|
||||
@@ -643,12 +643,12 @@ permissions["ask"] = ask
|
||||
|
||||
settings["permissions"] = permissions
|
||||
|
||||
settings_path.write_text(json.dumps(settings, indent=2) + "\n")
|
||||
settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
||||
print(f" hooks -> {settings_path}")
|
||||
print(f" AIPASS_HOME -> {repo_root} (in settings.json env)")
|
||||
PYEOF
|
||||
else
|
||||
echo "Skipping hooks (no .claude/hooks/ directory found)"
|
||||
echo "Skipping Claude hooks (bridge not found at src/aipass/hooks/apps/handlers/bridges/claude.py)"
|
||||
fi
|
||||
|
||||
# --- Install Claude Code commands (provider level) ---
|
||||
|
||||
@@ -55,6 +55,7 @@ drone @git pr "desc" # Push current branch and create PR to main
|
||||
drone @git dev-pr "desc" # Push dev and create PR to main
|
||||
drone @git merge <PR#> # Merge a PR and sync local main
|
||||
drone @git delete-branch <name> # Delete a remote branch (not main/dev)
|
||||
drone @git close-pr <number> # Close a PR by number
|
||||
drone @git branches # List remote branches
|
||||
drone @git sync # Pull latest (branch-aware: main or dev)
|
||||
drone @git sync --autostash # Sync with autostash for dirty trees
|
||||
@@ -173,6 +174,7 @@ drone/
|
||||
│ │ ├── dev_pr_handler.py # Push dev and create PR to main
|
||||
│ │ ├── branches_handler.py # List remote branches
|
||||
│ │ ├── delete_branch_handler.py # Delete remote branch (main/dev protected)
|
||||
│ │ ├── close_pr_handler.py # Close PR by number (gh pr close)
|
||||
│ │ ├── status_handler.py # Scoped git status (subprocess)
|
||||
│ │ └── sync_handler.py # Safe main sync (--autostash support)
|
||||
│ └── plugins/
|
||||
@@ -216,7 +218,7 @@ Auth centralized via `verify_git_access()` in `apps/plugins/devpulse_ops/auth.py
|
||||
| Tier | Who | Commands |
|
||||
|------|-----|----------|
|
||||
| **Global** | All branches | `status`, `diff`, `log`, `lock`, `branches`, `issue`, `run`, `workflow` |
|
||||
| **Owner** | `devpulse` only | `pr`, `commit`, `checkout`, `dev-pr`, `delete-branch`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` |
|
||||
| **Owner** | `devpulse` only | `pr`, `commit`, `checkout`, `dev-pr`, `delete-branch`, `close-pr`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` |
|
||||
|
||||
- Auth is checked once at the top of `git_module.handle_command()` before any handler is called
|
||||
- Unauthorized commands return a clear "Access denied" message with the caller's tier
|
||||
@@ -230,7 +232,7 @@ All work happens on `dev`. Only devpulse has write access. Agents build and repo
|
||||
**`pr` vs `dev-pr`:** `pr` works from any branch — on main it auto-creates a temp branch from the description slug (`main:<slug>`), on other branches it pushes directly. Does NOT use `-u` so main's upstream tracking stays on `origin/main`. `dev-pr` is specific to the dev→main workflow.
|
||||
|
||||
Enforcement layers:
|
||||
- `git_gate.py` PreToolUse hook blocks ALL raw git/gh commands
|
||||
- Git gate (PreToolUse hook) blocks ALL raw git/gh commands
|
||||
- Drone tier system restricts write commands to devpulse only
|
||||
- Prompt instructions tell agents they have zero git access
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -1,87 +1,107 @@
|
||||
# HOOKS — Branch Prompt
|
||||
# HOOKS -- Branch Prompt
|
||||
|
||||
<!--
|
||||
INSTRUCTIONS FOR FILLING OUT THIS TEMPLATE
|
||||
==========================================
|
||||
|
||||
This file is your local prompt. It gets injected every turn alongside the global prompt.
|
||||
The global prompt already covers: dispatch syntax, git workflow, hard rules, logging,
|
||||
memory system, breadcrumbs philosophy, and system-wide commands. Don't repeat any of that here.
|
||||
|
||||
Your job: fill out each section below with content specific to YOUR branch.
|
||||
Replace the guidance text (in italics) with real content, then delete this instruction block.
|
||||
|
||||
PRINCIPLES:
|
||||
- Breadcrumbs, not encyclopedias. Enough to navigate, not everything there is to know.
|
||||
- Operational, not descriptive. Tell the agent how to ACT, not just what things ARE.
|
||||
- Include concrete reference data — lookup tables, checklists, decision trees.
|
||||
- If you can run `drone @hooks --help` to get it, don't duplicate it here.
|
||||
- This file should be STABLE. If it changes every session, that content belongs in
|
||||
STATUS.local.md or .trinity/ instead.
|
||||
|
||||
WHEN YOU'RE DONE:
|
||||
- Delete this instruction block
|
||||
- Delete all italic guidance text
|
||||
- What remains should be pure operational reference
|
||||
-->
|
||||
|
||||
*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories, STATUS.local.md.*
|
||||
Injected every turn. Breadcrumbs only -- details in README, --help, .trinity/, STATUS.local.md.
|
||||
|
||||
## Identity
|
||||
|
||||
*One line. Who you are and what your role is. This is the first thing the agent reads every turn — make it count.*
|
||||
|
||||
You are HOOKS — {one-line role description}.
|
||||
HOOKS -- hook infrastructure owner. Single engine dispatches all hooks across platforms (Claude, Codex, Gemini) with per-project config, full logging, and crash isolation. Builder citizen. The 13th citizen.
|
||||
|
||||
## What I Do
|
||||
|
||||
*3-5 bullets covering what happens in this branch. Not a mission statement — concrete actions. Think "if someone asked what this branch does day-to-day, what would you say?"*
|
||||
- Own the hook engine -- receives events from platform bridges, routes to handlers, logs everything
|
||||
- Maintain 14 native handlers across 4 categories (prompt, security, lifecycle, notification)
|
||||
- Bridge platforms -- thin normalization layer per provider (Claude today, Codex/Gemini planned)
|
||||
- Per-project config -- `.aipass/hooks.json` controls what fires per project
|
||||
- Log everything -- prax integration + JSONL diagnostics for every hook execution
|
||||
|
||||
- {Primary responsibility}
|
||||
- {Secondary responsibility}
|
||||
- {What you build/maintain/operate}
|
||||
## What I Don't Do
|
||||
|
||||
- Touch provider settings directly -- setup.sh/doctor handles platform config installation
|
||||
- Manage other branches -- I'm a builder, not an orchestrator
|
||||
- Own handler business logic -- handlers are self-contained, engine just dispatches
|
||||
|
||||
## Key Commands
|
||||
|
||||
*The 5-8 commands you use most, with real arguments. Not your full command list — just the ones you'd need in 80% of sessions. Always show the full `drone @branch command [args]` syntax.*
|
||||
|
||||
```
|
||||
drone @hooks {command1} [args] # What it does
|
||||
drone @hooks {command2} [args] # What it does
|
||||
drone @hooks status # Show hook config for current project
|
||||
drone @hooks log # Tail recent hook activity (last 20 JSONL entries)
|
||||
drone @hooks test # Run hook test suite (planned)
|
||||
drone @hooks --help # Full help reference
|
||||
drone @hooks --version # Version info
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
*Your directory tree showing the code layout. Helps the agent find things without guessing. Skip this section entirely if your branch has no apps/ directory.*
|
||||
|
||||
```
|
||||
apps/
|
||||
├── hooks.py # Entry point
|
||||
├── modules/
|
||||
│ ├── {module1}.py # What it orchestrates
|
||||
│ └── {module2}.py # What it orchestrates
|
||||
└── handlers/
|
||||
├── {domain1}/ # What it handles
|
||||
└── {domain2}/ # What it handles
|
||||
hooks.py # Entry point (drone @hooks)
|
||||
modules/
|
||||
engine.py # Core dispatch -- routes events to handlers
|
||||
handlers/
|
||||
bridges/
|
||||
claude.py # Claude Code bridge (provider settings entry point)
|
||||
prompt/ # Prompt injection hooks
|
||||
branch_loader.py # Injects aipass_local_prompt.md
|
||||
global_loader.py # Injects global prompt
|
||||
identity.py # Injects passport identity block
|
||||
security/ # Enforcement hooks
|
||||
edit_gate.py # Blocks edits while type errors exist
|
||||
git_gate.py # Enforces git access tiers
|
||||
subagent_gate.py # Blocks sub-agent stop until clean
|
||||
lifecycle/ # Session management hooks
|
||||
auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
|
||||
auto_watchdog.py # Watchdog arming after dispatch
|
||||
compact.py # Pre-compact memory archival
|
||||
rollover.py # Pre-compact memory rollover
|
||||
notification/ # Alert hooks
|
||||
announce.py # Inbox banner on prompt
|
||||
email.py # Email notification
|
||||
stop_sound.py # Sound on session stop
|
||||
tool_sound.py # Sound on tool use
|
||||
config/
|
||||
loader.py # hooks.json discovery + validation
|
||||
diagnostics.py # Diagnostics config
|
||||
logs/
|
||||
engine.jsonl # JSONL diagnostics (every hook execution)
|
||||
tests/ # 15 test files, 244 tests
|
||||
```
|
||||
|
||||
## Handler Categories
|
||||
|
||||
| Category | Count | Handlers |
|
||||
|----------|-------|----------|
|
||||
| prompt | 3 | branch_loader, global_loader, identity |
|
||||
| security | 3 | edit_gate, git_gate, subagent_gate |
|
||||
| lifecycle | 4 | auto_fix, auto_watchdog, compact, rollover |
|
||||
| notification | 4 | announce, email, stop_sound, tool_sound |
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Provider settings point ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`)
|
||||
2. Bridge calls `engine.dispatch(event_type, stdin_data, config)`
|
||||
3. Engine reads `.aipass/hooks.json` (walks up from CWD)
|
||||
4. Engine runs matching hooks sequentially, logs each to JSONL
|
||||
5. `{"decision": "block"}` with exit code 2 = block the action
|
||||
6. Exit code 2 without JSON = crash (log error, continue to next hook)
|
||||
7. All hook stdout concatenated and returned to platform
|
||||
|
||||
## Integration
|
||||
|
||||
*Which branches you depend on or serve. Every branch connects to others — document those relationships so the agent knows who to ask and who's asking.*
|
||||
|
||||
- **Depends on:** @{branch} for {what}, @{branch} for {what}
|
||||
- **Serves:** @{branch} uses my {feature}, @{branch} calls my {command}
|
||||
- **Depends on:** @prax for logging (system_logger for prax monitor visibility)
|
||||
- **Serves:** All branches via hook dispatch -- every Claude Code session routes through the engine
|
||||
- **Standards:** @seedgo audits handler code quality
|
||||
- **Orchestration:** @devpulse dispatches build tasks to this branch
|
||||
|
||||
## Working Habits
|
||||
|
||||
*Behavioral patterns specific to this branch. How you approach work differently from other branches. Decision frameworks, common workflows, domain-specific patterns. Only include habits that are unique to this branch — if it applies to all branches, it's in the global prompt.*
|
||||
|
||||
- {Habit or pattern that shapes how you work}
|
||||
- {Decision framework or workflow unique to this domain}
|
||||
- Handlers are self-contained. One file per hook, one test file per handler. No cross-handler imports.
|
||||
- Crash isolation is non-negotiable. One broken hook never blocks the rest. Engine catches and logs.
|
||||
- Bridge layer stays thin. Normalization only -- no business logic in bridges.
|
||||
- Test everything in isolation. Handlers should be testable without the engine, engine without handlers.
|
||||
- Config walks up. `.aipass/hooks.json` is discovered by walking CWD upward, not hardcoded paths.
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
*Non-obvious quirks, hard-won lessons, things that will waste 20 minutes if you don't know them. These are the breadcrumbs that save time — the stuff you'd tell a new agent on day one.*
|
||||
|
||||
- {Gotcha or non-obvious behavior}
|
||||
- {Hard-won lesson from a past session}
|
||||
- Exit code 2 has dual meaning: intentional block (with JSON) vs crash (without JSON). Engine distinguishes by checking stdout.
|
||||
- JSONL log lives at `logs/engine.jsonl` -- not in prax. Prax gets a copy via system_logger, but JSONL is the source of truth for hook diagnostics.
|
||||
- Bridge must be the ONLY entry in provider settings per event type. Multiple entries per event = platform calls them all independently, bypassing engine sequencing.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"metadata": {
|
||||
"version": "1.1.0",
|
||||
"created": "2026-05-18",
|
||||
"updated": "2026-05-19",
|
||||
"updated": "2026-05-21",
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [
|
||||
@@ -110,6 +110,286 @@
|
||||
"file": "tests/test_engine.py",
|
||||
"standard": "exception_contracts",
|
||||
"reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/notification/announce.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Sound handler — no JSON operations, plays WAV files"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/notification/stop_sound.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Sound handler — no JSON operations, plays WAV files"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_stop_sound.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_stop_sound.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_stop_sound.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_stop_sound.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_announce.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_announce.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_announce.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_announce.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/notification/email.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/auto_watchdog.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/auto_fix.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/edit_gate.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/git_gate.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/subagent_gate.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/subagent_gate.py",
|
||||
"standard": "open_encoding",
|
||||
"reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_email.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_email.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_email.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_email.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_subagent_gate.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_subagent_gate.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_subagent_gate.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_subagent_gate.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_auto_fix.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_auto_fix.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_auto_fix.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_auto_fix.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_auto_fix.py",
|
||||
"standard": "commented_logger",
|
||||
"reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test — not a commented-out call"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_auto_fix.py",
|
||||
"standard": "trigger",
|
||||
"reason": "Test cleanup .unlink() removes temporary state files — not a production file deletion"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/prompt/identity.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Uses stdlib json.loads to read passport.json — no JSON file ops needing json_handler"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_identity.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_identity.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_identity.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_identity.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/prompt/branch_loader.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "No JSON operations — reads markdown files and outputs text"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_branch_loader.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_branch_loader.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_branch_loader.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_branch_loader.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/prompt/global_loader.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "No JSON operations — reads markdown file and outputs text"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_global_loader.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_global_loader.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_global_loader.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_global_loader.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/compact.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Uses stdlib json.loads for local.json reading — no JSON file ops needing json_handler"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/rollover.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_compact.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_compact.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_compact.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_compact.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_rollover.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_rollover.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_rollover.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_rollover.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers"
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -14,7 +14,9 @@ Claude Code bridge.
|
||||
Thin entry point called from ~/.claude/settings.json hook entries.
|
||||
Normalizes Claude Code's stdin/stdout format and calls the engine.
|
||||
|
||||
Called from provider settings as the sole hook entry point per event type.
|
||||
Supports two forms:
|
||||
claude.py EventType — dispatch ALL enabled hooks for that event
|
||||
claude.py EventType:hook_name — dispatch ONLY that one hook (separate output)
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -26,10 +28,15 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
def main() -> None:
|
||||
"""Entry point — receive event type from Claude Code, dispatch via engine."""
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write("Usage: claude.py <EventType>\n")
|
||||
sys.stderr.write("Usage: claude.py <EventType> or claude.py <EventType:hook_name>\n")
|
||||
sys.exit(1)
|
||||
|
||||
event_type = sys.argv[1]
|
||||
arg = sys.argv[1]
|
||||
hook_filter = None
|
||||
if ":" in arg:
|
||||
event_type, hook_filter = arg.split(":", 1)
|
||||
else:
|
||||
event_type = arg
|
||||
|
||||
stdin_data = ""
|
||||
if not sys.stdin.isatty():
|
||||
@@ -40,6 +47,11 @@ def main() -> None:
|
||||
config = {"hooks_enabled": True}
|
||||
logger.info("[HOOKS:claude] no project config found, using defaults")
|
||||
|
||||
if hook_filter:
|
||||
full_config: dict = config
|
||||
hook_def = full_config.get(event_type, {}).get(hook_filter, {})
|
||||
config = {"hooks_enabled": True, event_type: {hook_filter: hook_def}}
|
||||
|
||||
output = dispatch(event_type, stdin_data, config)
|
||||
if output:
|
||||
sys.stdout.write(output)
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: auto_fix.py
|
||||
# Version: 1.0.0
|
||||
# Description: Post-edit diagnostics — syntax, lint, type, pattern, seedgo checks (PostToolUse)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/lifecycle
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Runs diagnostics on edited files and surfaces errors for the agent to fix."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[HOOKS] auto_fix: speak error: %s", exc)
|
||||
|
||||
|
||||
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
||||
STATE_FILE = Path(__file__).parent.parent.parent.parent.parent / ".diagnostics_state.json"
|
||||
SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"}
|
||||
|
||||
PYTHON_PATTERNS = {
|
||||
"bad_optional": {
|
||||
"pattern": ": str = None",
|
||||
"message": "Optional param should use 'str | None = None' pattern",
|
||||
},
|
||||
"logger_debug": {
|
||||
"pattern": "logger.debug(",
|
||||
"message": "Use logger.info for SystemLogger (logger.debug not supported)",
|
||||
},
|
||||
"return_error_msg": {
|
||||
"pattern": "return error_msg",
|
||||
"message": "Return None for error states, not error_msg string",
|
||||
},
|
||||
"open_no_encoding": {
|
||||
"pattern": "open(",
|
||||
"requires_missing": "encoding=",
|
||||
"message": "open() without encoding='utf-8'",
|
||||
},
|
||||
"log_not_log_operation": {
|
||||
"pattern": ".log(",
|
||||
"message": "Use log_operation() with success/error params, not .log()",
|
||||
},
|
||||
"dict_none_no_check": {
|
||||
"pattern": "Dict | None",
|
||||
"message": "Dict | None return: Add None check before using (if result is None: return)",
|
||||
},
|
||||
}
|
||||
|
||||
JSON_CORRUPTION_CHARS = ["�", "\x00"]
|
||||
|
||||
|
||||
def _check_syntax(file_path: str) -> list[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "py_compile", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return [f"SYNTAX: {result.stderr.strip()}"]
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: py_compile failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _check_ruff_lint(file_path: str) -> list[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ruff", "check", "--select=E,F,W", "--output-format=text", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
return [f"LINT: {line}" for line in result.stdout.strip().split("\n")[:5]]
|
||||
except FileNotFoundError:
|
||||
logger.info("[HOOKS] auto_fix: ruff not found")
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: ruff lint failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _check_ruff_format(file_path: str) -> list[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ruff", "format", "--check", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
name = Path(file_path).name
|
||||
return [f"FORMAT: {name} needs ruff format (run: ruff format {name})"]
|
||||
except FileNotFoundError:
|
||||
logger.info("[HOOKS] auto_fix: ruff not found")
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: ruff format check failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _check_line_pattern(line: str, pattern: str) -> bool:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(("#", '"', "'")):
|
||||
return False
|
||||
if f'"{pattern}' in line or f"'{pattern}" in line:
|
||||
return False
|
||||
return pattern in line
|
||||
|
||||
|
||||
def _check_patterns(file_path: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
try:
|
||||
content = Path(file_path).read_text(encoding="utf-8")
|
||||
lines = content.split("\n")
|
||||
|
||||
for check in PYTHON_PATTERNS.values():
|
||||
pattern = check["pattern"]
|
||||
message = check["message"]
|
||||
requires_missing = check.get("requires_missing")
|
||||
|
||||
if requires_missing:
|
||||
if pattern in content and requires_missing not in content:
|
||||
errors.append(f"PATTERN: {message}")
|
||||
continue
|
||||
|
||||
for line in lines:
|
||||
if _check_line_pattern(line, pattern):
|
||||
errors.append(f"PATTERN: {message}")
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: pattern check failed: %s", exc)
|
||||
return errors
|
||||
|
||||
|
||||
def _run_python_checks(file_path: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
errors.extend(_check_syntax(file_path))
|
||||
errors.extend(_check_ruff_lint(file_path))
|
||||
errors.extend(_check_ruff_format(file_path))
|
||||
errors.extend(_check_patterns(file_path))
|
||||
return errors
|
||||
|
||||
|
||||
def _run_ruff_lint_structured(file_path: str) -> list[dict]:
|
||||
if "/.claude/hooks/" in file_path:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ruff", "check", "--select=E,F,W", "--output-format=json", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if not result.stdout.strip():
|
||||
return []
|
||||
violations = json.loads(result.stdout)
|
||||
if not isinstance(violations, list):
|
||||
return []
|
||||
errors: list[dict] = []
|
||||
for v in violations[:10]:
|
||||
line = v.get("location", {}).get("row", 0)
|
||||
code = v.get("code", "?")
|
||||
message = v.get("message", "unknown")[:100]
|
||||
errors.append({"line": line, "message": f"{code}: {message}"})
|
||||
return errors
|
||||
except FileNotFoundError:
|
||||
logger.info("[HOOKS] auto_fix: ruff not found for structured lint")
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.info("[HOOKS] auto_fix: ruff JSON parse failed: %s", exc)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.info("[HOOKS] auto_fix: ruff structured lint timed out")
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: ruff structured lint failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _run_pyright_check(file_path: str) -> list[dict]:
|
||||
if "/.claude/hooks/" in file_path:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pyright", "--outputjson", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.info("[HOOKS] auto_fix: pyright JSON parse failed: %s", exc)
|
||||
return []
|
||||
|
||||
errors: list[dict] = []
|
||||
for diag in data.get("generalDiagnostics", []):
|
||||
if diag.get("severity", "") == "error":
|
||||
line = diag.get("range", {}).get("start", {}).get("line", 0)
|
||||
message = diag.get("message", "Unknown error")
|
||||
errors.append({"line": line, "message": message[:100]})
|
||||
return errors[:10]
|
||||
except FileNotFoundError:
|
||||
logger.info("[HOOKS] auto_fix: pyright not installed")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.info("[HOOKS] auto_fix: pyright timed out")
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: pyright failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _run_seedgo_checklist(file_path: str) -> list[str]:
|
||||
if "/.claude/hooks/" in file_path:
|
||||
return []
|
||||
aipass_home = os.environ.get("AIPASS_HOME", "")
|
||||
if not aipass_home:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["drone", "@seedgo", "checklist", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
cwd=aipass_home,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
violations: list[str] = []
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("✗"):
|
||||
violation = line[1:].strip()
|
||||
if violation:
|
||||
violations.append(violation)
|
||||
return violations[:5]
|
||||
except FileNotFoundError:
|
||||
logger.info("[HOOKS] auto_fix: drone not found for seedgo checklist")
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: seedgo checklist failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _save_diagnostics_state(file_path: str, errors: list[dict]) -> None:
|
||||
try:
|
||||
if errors:
|
||||
state = {"file": str(Path(file_path).resolve()), "errors": errors}
|
||||
STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
|
||||
else:
|
||||
if STATE_FILE.exists():
|
||||
STATE_FILE.unlink()
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: state file write failed: %s", exc)
|
||||
|
||||
|
||||
def _check_emoji_list(items: list, key: str) -> str | None:
|
||||
for item in items:
|
||||
if not isinstance(item, str) or len(item) != 1:
|
||||
continue
|
||||
if ord(item) < 128 and item not in "✓✗":
|
||||
return f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}"
|
||||
return None
|
||||
|
||||
|
||||
def _run_json_checks(file_path: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
try:
|
||||
content = Path(file_path).read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.info("[HOOKS] auto_fix: json read failed: %s", e)
|
||||
return [f"READ ERROR: {e!s}"]
|
||||
|
||||
for char in JSON_CORRUPTION_CHARS:
|
||||
if char in content:
|
||||
errors.append(f"EMOJI CORRUPTION: Found corrupted character '{char!r}'")
|
||||
break
|
||||
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.info("[HOOKS] auto_fix: json syntax error in %s: %s", file_path, e)
|
||||
errors.append(f"JSON SYNTAX: {e.msg} at line {e.lineno}")
|
||||
return errors
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return errors
|
||||
|
||||
for key in ("allowed_emojis", "emojis", "emoji_list"):
|
||||
values = data.get(key)
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
finding = _check_emoji_list(values, key)
|
||||
if finding:
|
||||
errors.append(finding)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Run diagnostics on edited files and surface errors.
|
||||
|
||||
Args:
|
||||
hook_data: Parsed hook event dict from engine.
|
||||
|
||||
Returns:
|
||||
Result dict with stdout (JSON additionalContext or empty) and exit_code.
|
||||
"""
|
||||
try:
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
if tool_name not in EDIT_TOOLS:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
tool_input = hook_data.get("tool_input", {})
|
||||
file_path = tool_input.get("file_path", "")
|
||||
if not file_path:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
ext = Path(file_path).suffix.lower()
|
||||
if ext in SKIP_EXTENSIONS:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
_speak("auto fix diagnostics")
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
if file_path.endswith(".py"):
|
||||
errors = _run_python_checks(file_path)
|
||||
|
||||
seedgo_violations = _run_seedgo_checklist(file_path)
|
||||
for v in seedgo_violations:
|
||||
errors.append(f"SEEDGO: {v}")
|
||||
|
||||
type_errors = _run_pyright_check(file_path)
|
||||
for te in type_errors:
|
||||
errors.append(f"TYPE: L{te['line']}: {te['message']}")
|
||||
|
||||
ruff_lint_errors = _run_ruff_lint_structured(file_path)
|
||||
_save_diagnostics_state(file_path, ruff_lint_errors + type_errors)
|
||||
|
||||
elif file_path.endswith(".json"):
|
||||
errors = _run_json_checks(file_path)
|
||||
else:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
if errors:
|
||||
error_text = "\n".join(f" - {e}" for e in errors)
|
||||
context = (
|
||||
f"[AUTO-FIX] {len(errors)} error(s) in {Path(file_path).name}:\n"
|
||||
f"{error_text}\n\n"
|
||||
f"Fix these errors in {Path(file_path).name} now. Do not skip or defer."
|
||||
)
|
||||
result = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PostToolUse",
|
||||
"additionalContext": context,
|
||||
},
|
||||
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing",
|
||||
}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0}
|
||||
|
||||
result = {"systemMessage": "[diagnostics] ok"}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: unexpected error (allowing): %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -0,0 +1,77 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: auto_watchdog.py
|
||||
# Version: 1.0.0
|
||||
# Description: Reminds agent to arm watchdog after dispatch (PostToolUse)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/lifecycle
|
||||
# Created: 2026-05-21
|
||||
# Modified: 2026-05-21
|
||||
# =============================================
|
||||
|
||||
"""Checks for dispatch commands and reminds the agent to arm the watchdog."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[HOOKS] auto_watchdog: speak error: %s", exc)
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Return additionalContext reminder if dispatch detected without watchdog.
|
||||
|
||||
Args:
|
||||
hook_data: Parsed hook event dict from engine.
|
||||
|
||||
Returns:
|
||||
Result dict with stdout (JSON additionalContext or empty) and exit_code.
|
||||
"""
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
if tool_name != "Bash":
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
command = hook_data.get("tool_input", {}).get("command", "")
|
||||
|
||||
if "drone @ai_mail dispatch" not in command:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
if "unread_count" in command and "while [" in command:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
if "dispatch wake" in command and "dispatch @" not in command:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
_speak("auto watchdog")
|
||||
|
||||
result = {
|
||||
"additionalContext": (
|
||||
"[AUTO-WATCHDOG] Dispatch detected — arm watchdog NOW. "
|
||||
"Run the watchdog one-liner from your local prompt with "
|
||||
"run_in_background: true and timeout: 600000."
|
||||
)
|
||||
}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0}
|
||||
@@ -0,0 +1,169 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: compact.py
|
||||
# Version: 1.0.0
|
||||
# Description: Injects live state for post-compact recovery (PreCompact)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/lifecycle
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Reads branch state and injects recovery context before compaction."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[HOOKS] compact: speak error: %s", exc)
|
||||
|
||||
|
||||
def _find_branch_dir(cwd: str) -> Path | None:
|
||||
parts = Path(cwd).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass" and i > 0 and parts[i - 1] == "src":
|
||||
branch_dir = Path(*parts[: i + 2])
|
||||
if branch_dir.is_dir():
|
||||
return branch_dir
|
||||
if (Path(cwd) / ".trinity").is_dir():
|
||||
return Path(cwd)
|
||||
return None
|
||||
|
||||
|
||||
def _read_status_local(branch_dir: Path) -> str | None:
|
||||
for name in ("STATUS.local.md", "dev.local.md"):
|
||||
path = branch_dir / name
|
||||
if path.is_file():
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")[:3000]
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] compact: read status failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _read_last_session(branch_dir: Path) -> str | None:
|
||||
local_path = branch_dir / ".trinity" / "local.json"
|
||||
if not local_path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(local_path.read_text(encoding="utf-8"))
|
||||
result: list[str] = []
|
||||
sessions = data.get("sessions", [])
|
||||
if sessions:
|
||||
last = sessions[0]
|
||||
result.append(
|
||||
f"Last session (#{last.get('id', '?')}, {last.get('d', '?')}): {last.get('sum', 'no summary')}"
|
||||
)
|
||||
learnings = data.get("key_learnings", {})
|
||||
if learnings:
|
||||
keys = list(learnings.keys())[-10:]
|
||||
result.append(f"Key learnings available: {', '.join(keys)}")
|
||||
return "\n".join(result) if result else None
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] compact: read session failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _get_git_info() -> str | None:
|
||||
try:
|
||||
branch = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
dirty = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
result: list[str] = []
|
||||
if branch.returncode == 0:
|
||||
result.append(f"Git branch: {branch.stdout.strip()}")
|
||||
if dirty.returncode == 0 and dirty.stdout.strip():
|
||||
lines = dirty.stdout.strip().split("\n")
|
||||
result.append(f"Uncommitted changes: {len(lines)} files")
|
||||
return "\n".join(result) if result else None
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] compact: git info failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Inject live branch state for post-compact recovery."""
|
||||
_speak("pre compact")
|
||||
|
||||
try:
|
||||
cwd = hook_data.get("cwd", "") or str(Path.cwd())
|
||||
branch_dir = _find_branch_dir(cwd)
|
||||
branch_name = branch_dir.name if branch_dir else "unknown"
|
||||
|
||||
sections: list[str] = []
|
||||
sections.append(
|
||||
f"POST-COMPACT RECOVERY — @{branch_name}\n\n"
|
||||
"Context just compacted. Below is your live state. Use it to continue seamlessly."
|
||||
)
|
||||
|
||||
git_info = _get_git_info()
|
||||
if git_info:
|
||||
sections.append(f"## Git\n{git_info}")
|
||||
|
||||
if branch_dir:
|
||||
session_info = _read_last_session(branch_dir)
|
||||
if session_info:
|
||||
sections.append(f"## Last Session\n{session_info}")
|
||||
|
||||
status = _read_status_local(branch_dir)
|
||||
if status:
|
||||
sections.append(f"## STATUS.local.md\n{status}")
|
||||
|
||||
is_dispatched = os.environ.get("AIPASS_SESSION_TYPE") == "dispatched"
|
||||
if is_dispatched:
|
||||
sections.append(
|
||||
"## DISPATCHED AGENT — SAVE STATE NOW\n"
|
||||
"Before continuing work, you MUST update your memories:\n"
|
||||
"1. Update .trinity/local.json — add/update current session with work done so far\n"
|
||||
"2. Update STATUS.local.md — ensure Current Work reflects what you've accomplished\n"
|
||||
"3. Then continue your task from where the summary left off\n\n"
|
||||
"This is non-optional. Compaction just happened — if you don't save now, work history is lost."
|
||||
)
|
||||
else:
|
||||
sections.append(
|
||||
"## Recovery Protocol\n"
|
||||
"- Continue where the summary left off — don't restart or ask generic questions\n"
|
||||
"- .trinity/local.json has full session history and key_learnings — read it if you need more context\n"
|
||||
"- STATUS.local.md has current work, known issues, and todos\n"
|
||||
"- Save memories proactively — compaction just proved you need to\n"
|
||||
"- Match the conversation tone from before compaction"
|
||||
)
|
||||
|
||||
return {"stdout": "\n\n".join(sections), "exit_code": 0}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] compact: unexpected error: %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
Executable → Regular
+72
-65
@@ -1,36 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pre-Compact Rollover Hook — check branch memory files and run rollover if overdue.
|
||||
# =================== AIPass ====================
|
||||
# Name: rollover.py
|
||||
# Version: 1.0.0
|
||||
# Description: Checks branch memory files and runs rollover if overdue (PreCompact)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/lifecycle
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
Runs alongside pre_compact.py on PreCompact events. Scans all branches'
|
||||
.trinity files for over-limit conditions and executes rollover via drone
|
||||
if any are found. Stdout stays clean (pre_compact.py owns stdout for
|
||||
context injection). All logging goes to stderr.
|
||||
|
||||
Version: 1.0.0
|
||||
"""
|
||||
"""Scans all branches for over-limit memory files and triggers rollover via drone."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
def _find_repo_root():
|
||||
"""Find the AIPass repo root (contains AIPASS_REGISTRY.json)."""
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in [current] + list(current.parents):
|
||||
if (parent / "AIPASS_REGISTRY.json").exists():
|
||||
return parent
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[HOOKS] rollover: speak error: %s", exc)
|
||||
|
||||
|
||||
def _find_repo_root() -> Path | None:
|
||||
aipass_home = os.environ.get("AIPASS_HOME", "")
|
||||
if aipass_home:
|
||||
p = Path(aipass_home)
|
||||
if (p / "AIPASS_REGISTRY.json").exists():
|
||||
return p
|
||||
cwd = Path.cwd()
|
||||
for parent in [cwd] + list(cwd.parents):
|
||||
for parent in [cwd, *list(cwd.parents)]:
|
||||
if (parent / "AIPASS_REGISTRY.json").exists():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _read_registry(repo_root):
|
||||
"""Read branch list from AIPASS_REGISTRY.json."""
|
||||
def _read_registry(repo_root: Path) -> list[dict]:
|
||||
registry_path = repo_root / "AIPASS_REGISTRY.json"
|
||||
if not registry_path.exists():
|
||||
return []
|
||||
@@ -44,31 +69,26 @@ def _read_registry(repo_root):
|
||||
resolved = repo_root / raw_path
|
||||
branch["_resolved_path"] = resolved
|
||||
return branches
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] rollover: registry read failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _check_file(file_path):
|
||||
"""Check if a .trinity memory file is overdue for rollover.
|
||||
|
||||
Returns (overdue: bool, description: str) or (False, "") if not overdue.
|
||||
"""
|
||||
def _check_file(file_path: Path) -> tuple[bool, str]:
|
||||
if not file_path.is_file():
|
||||
return False, ""
|
||||
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] rollover: file parse failed %s: %s", file_path, exc)
|
||||
return False, ""
|
||||
|
||||
metadata = data.get("document_metadata", {})
|
||||
schema_version = metadata.get("schema_version", "1.0.0")
|
||||
limits = metadata.get("limits", {})
|
||||
limits = data.get("document_metadata", {}).get("limits", {})
|
||||
|
||||
has_v2_limits = any(k in limits for k in ("max_sessions", "max_key_learnings", "max_observations"))
|
||||
if has_v2_limits:
|
||||
reasons = []
|
||||
reasons: list[str] = []
|
||||
max_sessions = limits.get("max_sessions")
|
||||
if max_sessions is not None:
|
||||
sessions = data.get("sessions", [])
|
||||
@@ -91,7 +111,6 @@ def _check_file(file_path):
|
||||
return True, ", ".join(reasons)
|
||||
return False, ""
|
||||
|
||||
# v1: line-count based
|
||||
max_lines = limits.get("max_lines", 600)
|
||||
current_lines = raw.count("\n") + 1
|
||||
if current_lines >= max_lines:
|
||||
@@ -99,28 +118,23 @@ def _check_file(file_path):
|
||||
return False, ""
|
||||
|
||||
|
||||
def _find_overdue(repo_root):
|
||||
"""Scan all branches for overdue memory files. Returns list of (branch, type, reason)."""
|
||||
def _find_overdue(repo_root: Path) -> list[tuple[str, str, str]]:
|
||||
branches = _read_registry(repo_root)
|
||||
overdue = []
|
||||
|
||||
overdue: list[tuple[str, str, str]] = []
|
||||
for branch in branches:
|
||||
name = branch.get("name", "unknown")
|
||||
branch_path = branch.get("_resolved_path")
|
||||
if not branch_path or not branch_path.is_dir():
|
||||
continue
|
||||
|
||||
for memory_type in ["local", "observations"]:
|
||||
for memory_type in ("local", "observations"):
|
||||
file_path = branch_path / ".trinity" / f"{memory_type}.json"
|
||||
is_overdue, reason = _check_file(file_path)
|
||||
if is_overdue:
|
||||
overdue.append((name, memory_type, reason))
|
||||
|
||||
return overdue
|
||||
|
||||
|
||||
def _run_rollover(repo_root):
|
||||
"""Execute rollover via drone subprocess. Returns (success, output)."""
|
||||
def _run_rollover(repo_root: Path) -> tuple[bool, str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["drone", "@memory", "rollover", "run"],
|
||||
@@ -131,44 +145,37 @@ def _run_rollover(repo_root):
|
||||
)
|
||||
return result.returncode == 0, result.stdout + result.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.info("[HOOKS] rollover: drone rollover timed out (110s)")
|
||||
return False, "Rollover timed out (110s)"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] rollover: drone rollover failed: %s", exc)
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main hook entry point."""
|
||||
try:
|
||||
json.load(sys.stdin)
|
||||
except Exception:
|
||||
pass
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Check memory files for overflow and trigger rollover if needed."""
|
||||
_speak("pre compact rollover")
|
||||
|
||||
try:
|
||||
repo_root = _find_repo_root()
|
||||
if not repo_root:
|
||||
sys.exit(0)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
overdue = _find_overdue(repo_root)
|
||||
if not overdue:
|
||||
sys.exit(0)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
summary = "; ".join(f"{name}.{mtype} ({reason})" for name, mtype, reason in overdue)
|
||||
print(f"Pre-compact rollover: {len(overdue)} overdue — {summary}", file=sys.stderr)
|
||||
logger.info("[HOOKS] rollover: %d overdue — %s", len(overdue), summary)
|
||||
|
||||
success, output = _run_rollover(repo_root)
|
||||
if success:
|
||||
print(f"Pre-compact rollover: complete ({len(overdue)} files processed)", file=sys.stderr)
|
||||
logger.info("[HOOKS] rollover: complete (%d files processed)", len(overdue))
|
||||
else:
|
||||
print(f"Pre-compact rollover: failed — {output[:200]}", file=sys.stderr)
|
||||
logger.info("[HOOKS] rollover: failed — %s", output[:200])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Pre-compact rollover error: {e}", file=sys.stderr)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("PreCompact", "provider", __file__, main)
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] rollover: unexpected error: %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -0,0 +1,84 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: announce.py
|
||||
# Version: 1.1.0
|
||||
# Description: Plays announcement tone on Notification events
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/notification
|
||||
# Created: 2026-05-20
|
||||
# Modified: 2026-05-20
|
||||
# =============================================
|
||||
|
||||
"""Plays announcement tone + Piper voice ID on Notification events."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", ""))
|
||||
SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds"
|
||||
SOUND_FILE = SOUNDS_DIR / "mixkit-clear-announce-tones-2861.wav"
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _play(sound_path: Path) -> None:
|
||||
"""Play a WAV file via aplay (fire-and-forget)."""
|
||||
if not sound_path.exists():
|
||||
logger.info("[HOOKS] announce: file not found: %s", sound_path)
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["aplay", "-q", str(sound_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] announce: playback error: %s", exc)
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
"""Generate speech via Piper TTS and play it (fire-and-forget)."""
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
logger.info("[HOOKS] announce: piper not available")
|
||||
return
|
||||
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
|
||||
piper_result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if piper_result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(
|
||||
["aplay", "-q", wav_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.info("[HOOKS] announce: piper timed out")
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] announce: speak error: %s", exc)
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Play notification tone and speak hook name for identification.
|
||||
|
||||
Args:
|
||||
hook_data: Parsed hook event dict from engine.
|
||||
|
||||
Returns:
|
||||
Result dict with stdout (empty) and exit_code.
|
||||
"""
|
||||
_speak("notification sound")
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -0,0 +1,138 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: email.py
|
||||
# Version: 1.1.0
|
||||
# Description: Checks inbox for unread emails on UserPromptSubmit
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/notification
|
||||
# Created: 2026-05-21
|
||||
# Modified: 2026-05-21
|
||||
# =============================================
|
||||
|
||||
"""Checks branch inbox for unread emails and returns notification text."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
"""Generate speech via Piper TTS and play it (fire-and-forget)."""
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
logger.info("[HOOKS] email: piper not available")
|
||||
return
|
||||
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
|
||||
piper_result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if piper_result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(
|
||||
["aplay", "-q", wav_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.info("[HOOKS] email: piper timed out")
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] email: speak error: %s", exc)
|
||||
|
||||
|
||||
def _find_branch_root() -> Path | None:
|
||||
"""Find the branch root by walking up from CWD looking for branch markers."""
|
||||
cwd = Path.cwd()
|
||||
repo_root = _find_repo_root()
|
||||
if not repo_root:
|
||||
return None
|
||||
|
||||
search = cwd
|
||||
for _ in range(10):
|
||||
has_trinity = (search / ".trinity").is_dir()
|
||||
has_apps = (search / "apps").is_dir()
|
||||
has_mail = (search / ".ai_mail.local").is_dir() or (search / "ai_mail.local").is_dir()
|
||||
|
||||
if (has_trinity or has_apps or has_mail) and search != repo_root:
|
||||
return search
|
||||
|
||||
if search == repo_root:
|
||||
break
|
||||
|
||||
parent = search.parent
|
||||
if parent == search:
|
||||
break
|
||||
search = parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_repo_root() -> Path | None:
|
||||
"""Find the repo root (contains pyproject.toml or .git)."""
|
||||
search = Path.cwd()
|
||||
while search.parent != search:
|
||||
if (search / "pyproject.toml").exists() or (search / ".git").is_dir():
|
||||
return search
|
||||
search = search.parent
|
||||
return None
|
||||
|
||||
|
||||
def _count_new_emails(branch_root: Path) -> int:
|
||||
"""Count unread emails in the branch's inbox."""
|
||||
inbox_path = branch_root / ".ai_mail.local" / "inbox.json"
|
||||
if not inbox_path.exists():
|
||||
inbox_path = branch_root / "ai_mail.local" / "inbox.json"
|
||||
|
||||
if not inbox_path.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
data = json.loads(inbox_path.read_text(encoding="utf-8"))
|
||||
messages = data if isinstance(data, list) else data.get("messages", [])
|
||||
count = 0
|
||||
for msg in messages:
|
||||
if msg.get("status") == "new":
|
||||
count += 1
|
||||
elif msg.get("status") is None and not msg.get("read", False):
|
||||
count += 1
|
||||
return count
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.info("[HOOKS] email: inbox read error: %s", exc)
|
||||
return 0
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Check inbox and return email notification if unread messages exist.
|
||||
|
||||
Args:
|
||||
hook_data: Parsed hook event dict from engine.
|
||||
|
||||
Returns:
|
||||
Result dict with stdout (notification text or empty) and exit_code.
|
||||
"""
|
||||
branch_root = _find_branch_root()
|
||||
if not branch_root:
|
||||
logger.info("[HOOKS] email: no branch root found")
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
new_count = _count_new_emails(branch_root)
|
||||
if new_count == 0:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
plural = "s" if new_count != 1 else ""
|
||||
_speak(f"email notification: {new_count} new email{plural}")
|
||||
msg = f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>"
|
||||
logger.info("[HOOKS] email: %d new email%s", new_count, plural)
|
||||
return {"stdout": msg, "exit_code": 0}
|
||||
@@ -0,0 +1,87 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: stop_sound.py
|
||||
# Version: 1.1.0
|
||||
# Description: Plays achievement bell + Piper voice on Stop events
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/notification
|
||||
# Created: 2026-05-20
|
||||
# Modified: 2026-05-20
|
||||
# =============================================
|
||||
|
||||
"""Plays achievement bell when the AI finishes responding (Stop event)."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", ""))
|
||||
SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds"
|
||||
SOUND_FILE = SOUNDS_DIR / "mixkit-achievement-bell-600.wav"
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _play(sound_path: Path) -> None:
|
||||
"""Play a WAV file via aplay (fire-and-forget)."""
|
||||
if not sound_path.exists():
|
||||
logger.info("[HOOKS] stop_sound: file not found: %s", sound_path)
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["aplay", "-q", str(sound_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] stop_sound: playback error: %s", exc)
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
"""Generate speech via Piper TTS and play it (fire-and-forget)."""
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
logger.info("[HOOKS] stop_sound: piper not available")
|
||||
return
|
||||
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
|
||||
piper_result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if piper_result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(
|
||||
["aplay", "-q", wav_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.info("[HOOKS] stop_sound: piper timed out")
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] stop_sound: speak error: %s", exc)
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Play achievement bell and speak hook name on Stop event.
|
||||
|
||||
Args:
|
||||
hook_data: Parsed hook event dict from engine.
|
||||
|
||||
Returns:
|
||||
Result dict with stdout (empty) and exit_code.
|
||||
"""
|
||||
if hook_data.get("stop_hook_active", False):
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
_speak("stop sound")
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -0,0 +1,85 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: branch_loader.py
|
||||
# Version: 1.0.0
|
||||
# Description: Loads branch-specific prompt + private integrations (UserPromptSubmit)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/prompt
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Loads .aipass/aipass_local_prompt.md and private integration prompts for injection."""
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[HOOKS] branch_loader: speak error: %s", exc)
|
||||
|
||||
|
||||
def _find_branch_root(cwd: str) -> Path | None:
|
||||
"""Walk up from CWD looking for .trinity/ or apps/ — stop at repo root."""
|
||||
search = Path(cwd).resolve()
|
||||
while search.parent != search:
|
||||
if (search / ".trinity").is_dir() or (search / "apps").is_dir():
|
||||
return search
|
||||
if (search / "pyproject.toml").exists() or (search / ".git").is_dir():
|
||||
return None
|
||||
search = search.parent
|
||||
return None
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Load branch prompt and private integration prompts."""
|
||||
_speak("branch prompt")
|
||||
|
||||
try:
|
||||
cwd = hook_data.get("cwd", "") or str(Path.cwd())
|
||||
branch_root = _find_branch_root(cwd)
|
||||
if not branch_root:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
prompt_file = branch_root / ".aipass" / "aipass_local_prompt.md"
|
||||
if prompt_file.exists():
|
||||
content = prompt_file.read_text(encoding="utf-8").strip()
|
||||
branch_name = branch_root.name.upper()
|
||||
parts.append(f"# Branch Context: {branch_name}\n<!-- Source: {prompt_file} -->\n{content}")
|
||||
|
||||
integrations_dir = branch_root / "apps" / "integrations"
|
||||
if integrations_dir.is_dir():
|
||||
for prompt in sorted(integrations_dir.glob("*/private_prompt.md")):
|
||||
parts.append(prompt.read_text(encoding="utf-8").strip())
|
||||
|
||||
if not parts:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
return {"stdout": "\n".join(parts), "exit_code": 0}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] branch_loader: unexpected error: %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -0,0 +1,62 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: global_loader.py
|
||||
# Version: 1.0.0
|
||||
# Description: Loads AIPass global prompt for injection (UserPromptSubmit)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/prompt
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Loads .aipass/aipass_global_prompt.md from AIPASS_HOME for prompt injection."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[HOOKS] global_loader: speak error: %s", exc)
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Load AIPass global prompt from AIPASS_HOME."""
|
||||
_speak("global prompt")
|
||||
|
||||
try:
|
||||
aipass_home = os.environ.get("AIPASS_HOME", "")
|
||||
if not aipass_home:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
prompt_file = Path(aipass_home) / ".aipass" / "aipass_global_prompt.md"
|
||||
if not prompt_file.exists():
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
content = prompt_file.read_text(encoding="utf-8")
|
||||
return {"stdout": content, "exit_code": 0}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] global_loader: unexpected error: %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -0,0 +1,113 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: identity.py
|
||||
# Version: 1.0.0
|
||||
# Description: Injects branch identity from passport.json (UserPromptSubmit)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/prompt
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Reads .trinity/passport.json and outputs formatted identity for prompt injection."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[HOOKS] identity: speak error: %s", exc)
|
||||
|
||||
|
||||
def _find_passport(cwd: str) -> Path | None:
|
||||
"""Walk up from CWD looking for .trinity/passport.json."""
|
||||
search = Path(cwd).resolve()
|
||||
home = Path.home()
|
||||
while search != home and search.parent != search:
|
||||
passport = search / ".trinity" / "passport.json"
|
||||
if passport.exists():
|
||||
return passport
|
||||
search = search.parent
|
||||
return None
|
||||
|
||||
|
||||
def _format_identity(data: dict) -> str:
|
||||
lines: list[str] = []
|
||||
|
||||
branch = data.get("branch_info", {})
|
||||
identity = data.get("identity", {})
|
||||
name = branch.get("branch_name") or identity.get("name", "UNKNOWN")
|
||||
lines.append(f"# {name} Identity")
|
||||
lines.append(f"Path: {branch.get('path', 'unknown')}")
|
||||
lines.append(f"Email: {branch.get('email', 'unknown')}")
|
||||
|
||||
if identity.get("role"):
|
||||
lines.append(f"Role: {identity['role']}")
|
||||
|
||||
traits = identity.get("traits") or data.get("traits")
|
||||
if traits:
|
||||
if isinstance(traits, list):
|
||||
lines.append("Traits: " + " | ".join(traits))
|
||||
else:
|
||||
lines.append(f"Traits: {traits}")
|
||||
|
||||
if identity.get("purpose"):
|
||||
lines.append(f"Purpose: {identity['purpose']}")
|
||||
|
||||
what_i_do = identity.get("what_i_do", [])
|
||||
if what_i_do:
|
||||
lines.append("Do: " + " | ".join(what_i_do[:4]))
|
||||
|
||||
what_i_dont_do = identity.get("what_i_dont_do", [])
|
||||
if what_i_dont_do:
|
||||
lines.append("Don't: " + " | ".join(what_i_dont_do[:3]))
|
||||
|
||||
principles = data.get("principles", [])
|
||||
if principles:
|
||||
lines.append("Principles: " + " * ".join(principles))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Inject branch identity from passport.json into prompt context."""
|
||||
_speak("identity")
|
||||
|
||||
try:
|
||||
cwd = hook_data.get("cwd", "") or str(Path.cwd())
|
||||
passport = _find_passport(cwd)
|
||||
if not passport:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
data = json.loads(passport.read_text(encoding="utf-8"))
|
||||
output = _format_identity(data)
|
||||
if not output:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
return {"stdout": f"\n{output}", "exit_code": 0}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] identity: unexpected error: %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -0,0 +1,160 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: edit_gate.py
|
||||
# Version: 1.0.0
|
||||
# Description: Cross-branch and inbox write protection (PreToolUse)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/security
|
||||
# Created: 2026-05-21
|
||||
# Modified: 2026-05-21
|
||||
# =============================================
|
||||
|
||||
"""Blocks unsafe edits: inbox writes, daemon confinement, cross-branch writes, diagnostics state."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[HOOKS] edit_gate: speak error: %s", exc)
|
||||
|
||||
|
||||
STATE_FILE = Path(__file__).parent.parent.parent.parent.parent / ".diagnostics_state.json"
|
||||
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
||||
TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn")
|
||||
|
||||
|
||||
def _get_branch(file_path: str) -> str:
|
||||
parts = Path(file_path).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return ""
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Apply edit security gates and return block or allow decision.
|
||||
|
||||
Args:
|
||||
hook_data: Parsed hook event dict from engine.
|
||||
|
||||
Returns:
|
||||
Result dict with stdout (block JSON or empty) and exit_code.
|
||||
"""
|
||||
_speak("edit gate")
|
||||
|
||||
try:
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
tool_input = hook_data.get("tool_input", {})
|
||||
file_path = tool_input.get("file_path", "")
|
||||
|
||||
if tool_name not in EDIT_TOOLS:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
if not file_path:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
fp = Path(file_path)
|
||||
if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts:
|
||||
reason = 'Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @<branch> "Subject" "Body"'
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
|
||||
cwd = hook_data.get("cwd", "") or os.getcwd()
|
||||
cwd_branch = _get_branch(cwd)
|
||||
|
||||
session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive")
|
||||
if session_type == "daemon" and cwd_branch:
|
||||
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
|
||||
if target_branch and target_branch != cwd_branch:
|
||||
reason = (
|
||||
f"Dispatched agent confined to own branch: '{cwd_branch}' "
|
||||
f"cannot write to '{target_branch}' in daemon mode."
|
||||
)
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
repo_root = None
|
||||
for parent in Path(cwd).parents:
|
||||
if (parent / ".git").exists():
|
||||
repo_root = parent
|
||||
break
|
||||
if repo_root and not target_branch:
|
||||
allowed_prefix = str(repo_root / "src" / "aipass" / cwd_branch)
|
||||
resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp)
|
||||
if not resolved.startswith(allowed_prefix):
|
||||
reason = f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}"
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
|
||||
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
|
||||
|
||||
if cwd_branch and target_branch and cwd_branch != target_branch:
|
||||
if cwd_branch not in TRUSTED_CROSS_WRITERS:
|
||||
reason = (
|
||||
f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n"
|
||||
f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}"
|
||||
)
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
|
||||
if not file_path.endswith(".py"):
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
if not STATE_FILE.exists():
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
try:
|
||||
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, IOError) as exc:
|
||||
logger.info("[HOOKS] edit_gate: diagnostics_state unreadable: %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
errored_file = state.get("file", "")
|
||||
errors = state.get("errors", [])
|
||||
|
||||
if not errors:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
try:
|
||||
current = str(Path(file_path).resolve())
|
||||
errored = str(Path(errored_file).resolve())
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.info("[HOOKS] edit_gate: path resolution failed: %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
if current == errored:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
current_branch = _get_branch(current)
|
||||
errored_branch = _get_branch(errored)
|
||||
if not errored_branch:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
if current_branch and errored_branch and current_branch != errored_branch:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5])
|
||||
reason = f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}"
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] edit_gate: unexpected error (allowing): %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -0,0 +1,148 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: git_gate.py
|
||||
# Version: 1.0.0
|
||||
# Description: Blocks raw git/gh commands and protected file edits (PreToolUse)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/security
|
||||
# Created: 2026-05-21
|
||||
# Modified: 2026-05-21
|
||||
# =============================================
|
||||
|
||||
"""Blocks raw git/gh commands and edits to settings/hooks files."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[HOOKS] git_gate: speak error: %s", exc)
|
||||
|
||||
|
||||
RAW_GIT_RE = re.compile(r"(?<![@\w/.])git\s")
|
||||
RAW_GH_RE = re.compile(r"(?<![@\w/.])gh\s")
|
||||
|
||||
GH_ALLOWED_SUBCOMMANDS = ("api",)
|
||||
|
||||
BLOCKED_EDIT_PATTERNS = [
|
||||
re.compile(r"/\.claude/settings(\.local)?\.json$"),
|
||||
re.compile(r"/\.claude/hooks/"),
|
||||
re.compile(r"/\.git/hooks/"),
|
||||
]
|
||||
|
||||
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
||||
|
||||
TRUSTED_HOOK_EDITORS = ("devpulse", "seedgo")
|
||||
|
||||
GIT_GH_REDIRECT = (
|
||||
"All git/gh commands are blocked. Use drone instead:\n"
|
||||
" drone @git status # working tree status\n"
|
||||
" drone @git diff # see changes\n"
|
||||
" drone @git log # commit history\n"
|
||||
" drone @git smart-sync # fetch + rebase\n"
|
||||
" drone @git sync # checkout main + pull\n"
|
||||
" drone @git issue list # GitHub issues\n"
|
||||
" drone @git run list # CI runs\n"
|
||||
" drone @git workflow run # trigger workflows"
|
||||
)
|
||||
|
||||
EDIT_REDIRECT = (
|
||||
"{path} is protected — settings.json, .claude/hooks/, and .git/hooks/ "
|
||||
"govern the enforcement layer itself.\n"
|
||||
"If a real change is needed, ask devpulse to make it directly."
|
||||
)
|
||||
|
||||
_BLOCK_ALLOW = {"stdout": "", "exit_code": 0}
|
||||
|
||||
|
||||
def _cwd_branch(cwd: str) -> str:
|
||||
parts = Path(cwd).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return ""
|
||||
|
||||
|
||||
def _is_allowed_gh(cmd: str) -> bool:
|
||||
match = re.search(r"(?<![@\w/.])gh\s+(\w+)", cmd)
|
||||
if match:
|
||||
return match.group(1) in GH_ALLOWED_SUBCOMMANDS
|
||||
return False
|
||||
|
||||
|
||||
def _block(reason: str) -> dict:
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
|
||||
|
||||
def _check_bash(tool_input: dict) -> dict:
|
||||
cmd = tool_input.get("command", "")
|
||||
if not cmd:
|
||||
return _BLOCK_ALLOW
|
||||
scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd)
|
||||
scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan)
|
||||
if RAW_GIT_RE.search(scan):
|
||||
return _block(GIT_GH_REDIRECT)
|
||||
if RAW_GH_RE.search(scan) and not _is_allowed_gh(cmd):
|
||||
return _block(GIT_GH_REDIRECT)
|
||||
return _BLOCK_ALLOW
|
||||
|
||||
|
||||
def _check_edit(tool_input: dict, cwd: str) -> dict:
|
||||
file_path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
|
||||
if not file_path:
|
||||
return _BLOCK_ALLOW
|
||||
for pat in BLOCKED_EDIT_PATTERNS:
|
||||
if pat.search(file_path):
|
||||
if _cwd_branch(cwd) in TRUSTED_HOOK_EDITORS:
|
||||
return _BLOCK_ALLOW
|
||||
return _block(EDIT_REDIRECT.format(path=file_path))
|
||||
return _BLOCK_ALLOW
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Block raw git/gh commands and protected file edits.
|
||||
|
||||
Args:
|
||||
hook_data: Parsed hook event dict from engine.
|
||||
|
||||
Returns:
|
||||
Result dict with stdout (block JSON or empty) and exit_code.
|
||||
"""
|
||||
_speak("git gate")
|
||||
|
||||
try:
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
tool_input = hook_data.get("tool_input", {})
|
||||
cwd = hook_data.get("cwd", "") or os.getcwd()
|
||||
if tool_name == "Bash":
|
||||
return _check_bash(tool_input)
|
||||
if tool_name in EDIT_TOOLS:
|
||||
return _check_edit(tool_input, cwd)
|
||||
return _BLOCK_ALLOW
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] git_gate: unexpected error (allowing): %s", exc)
|
||||
return _BLOCK_ALLOW
|
||||
@@ -0,0 +1,203 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: subagent_gate.py
|
||||
# Version: 1.0.0
|
||||
# Description: Checks modified Python files against seedgo standards on SubagentStop
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/security
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Checks modified Python files against seedgo standards and blocks on violations."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
_ALLOW = {"stdout": "", "exit_code": 0}
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return
|
||||
try:
|
||||
wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, mode="wb")
|
||||
wav_path = wav_file.name
|
||||
wav_file.close()
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[HOOKS] subagent_gate: speak error: %s", exc)
|
||||
|
||||
|
||||
def _block(reason: str) -> dict:
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
|
||||
|
||||
def _find_repo_root(cwd: str) -> Path | None:
|
||||
"""Walk up from AIPASS_HOME or CWD to find the git repo root."""
|
||||
for start in (os.environ.get("AIPASS_HOME", ""), cwd):
|
||||
if not start:
|
||||
continue
|
||||
p = Path(start)
|
||||
while p != p.parent:
|
||||
if (p / ".git").exists():
|
||||
return p
|
||||
p = p.parent
|
||||
return None
|
||||
|
||||
|
||||
def _get_cwd_branch(cwd: str, repo_root: Path) -> str | None:
|
||||
"""Detect which branch directory (src/aipass/<name>) the CWD is in."""
|
||||
src = repo_root / "src" / "aipass"
|
||||
try:
|
||||
rel = Path(cwd).resolve().relative_to(src)
|
||||
return rel.parts[0] if rel.parts else None
|
||||
except ValueError:
|
||||
logger.info("[HOOKS] subagent_gate: CWD %s not inside src/aipass", cwd)
|
||||
return None
|
||||
|
||||
|
||||
def _get_modified_py_files(cwd: str, repo_root: Path) -> list[str]:
|
||||
"""Get modified .py files scoped to the CWD branch via drone."""
|
||||
cwd_branch = _get_cwd_branch(cwd, repo_root)
|
||||
branch_dir = repo_root / "src" / "aipass" / cwd_branch if cwd_branch else None
|
||||
if not branch_dir or not branch_dir.exists():
|
||||
return []
|
||||
result = subprocess.run(
|
||||
["drone", "@git", "status"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
cwd=str(branch_dir),
|
||||
)
|
||||
files: list[str] = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line or "file(s) changed" in line:
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
_, filepath = parts
|
||||
if not filepath.endswith(".py") or filepath.startswith(".claude/"):
|
||||
continue
|
||||
full = repo_root / filepath
|
||||
if full.exists():
|
||||
files.append(str(full))
|
||||
return files
|
||||
|
||||
|
||||
def _run_seedgo_checklist(file_path: str, repo_root: Path) -> list[str]:
|
||||
"""Run seedgo checklist on a single file, return violation strings."""
|
||||
if "/.claude/" in file_path:
|
||||
return []
|
||||
result = subprocess.run(
|
||||
["drone", "@seedgo", "checklist", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
violations: list[str] = []
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("✗"):
|
||||
v = line[1:].strip()
|
||||
if v:
|
||||
violations.append(v)
|
||||
return violations[:5]
|
||||
|
||||
|
||||
def _check_hook_readme_accountability(cwd: str, repo_root: Path) -> str | None:
|
||||
"""Return advisory if hook files changed without README update."""
|
||||
cwd_branch = _get_cwd_branch(cwd, repo_root)
|
||||
branch_dir = repo_root / "src" / "aipass" / cwd_branch if cwd_branch else None
|
||||
if not branch_dir or not branch_dir.exists():
|
||||
return None
|
||||
result = subprocess.run(
|
||||
["drone", "@git", "status", "--all"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
cwd=str(branch_dir),
|
||||
)
|
||||
changed: list[str] = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line or "file(s) changed" in line:
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
changed.append(parts[1])
|
||||
|
||||
hook_files_changed = any(f.startswith(".claude/hooks/") and f.endswith(".py") for f in changed)
|
||||
readme_changed = ".claude/hooks/README.md" in changed
|
||||
|
||||
if hook_files_changed and not readme_changed:
|
||||
return (
|
||||
"Hook files were modified but .claude/hooks/README.md was not updated. "
|
||||
"Consider updating the README to reflect your changes."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Check modified files against seedgo standards on subagent stop."""
|
||||
_speak("subagent stop gate")
|
||||
|
||||
try:
|
||||
cwd = hook_data.get("cwd", "") or os.getcwd()
|
||||
repo_root = _find_repo_root(cwd)
|
||||
if repo_root is None:
|
||||
return _ALLOW
|
||||
|
||||
modified = _get_modified_py_files(cwd, repo_root)
|
||||
if not modified:
|
||||
return _ALLOW
|
||||
|
||||
readme_reminder = _check_hook_readme_accountability(cwd, repo_root)
|
||||
|
||||
all_violations: dict[str, list[str]] = {}
|
||||
for f in modified:
|
||||
vs = _run_seedgo_checklist(f, repo_root)
|
||||
if vs:
|
||||
name = Path(f).name
|
||||
all_violations[name] = vs
|
||||
|
||||
if all_violations:
|
||||
lines = ["Standards violations found in files you modified:\n"]
|
||||
for fname, vs in all_violations.items():
|
||||
lines.append(f" {fname}:")
|
||||
for v in vs:
|
||||
lines.append(f" - {v}")
|
||||
lines.append("\nFix these violations before finishing.")
|
||||
if readme_reminder:
|
||||
lines.append(f"\n{readme_reminder}")
|
||||
return _block("\n".join(lines))
|
||||
|
||||
if readme_reminder:
|
||||
result_data = {"decision": "allow", "reason": readme_reminder}
|
||||
return {"stdout": json.dumps(result_data), "exit_code": 0}
|
||||
|
||||
return _ALLOW
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] subagent_gate: unexpected error (allowing): %s", exc)
|
||||
return _ALLOW
|
||||
@@ -0,0 +1,168 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_announce.py
|
||||
# Version: 1.1.0
|
||||
# Description: Tests for announce notification handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-20
|
||||
# Modified: 2026-05-20
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/notification/announce.py."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestAnnounceHandler:
|
||||
"""Core handler behavior tests."""
|
||||
|
||||
def test_handle_returns_result_dict(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import handle
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.announce._play"),
|
||||
patch("aipass.hooks.apps.handlers.notification.announce._speak"),
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_handle_speaks_notification_sound(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import handle
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.announce._play"),
|
||||
patch("aipass.hooks.apps.handlers.notification.announce._speak") as mock_speak,
|
||||
):
|
||||
handle({})
|
||||
|
||||
mock_speak.assert_called_once_with("notification sound")
|
||||
|
||||
def test_handle_does_not_play_wav(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import handle
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.announce._play") as mock_play,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce._speak"),
|
||||
):
|
||||
handle({})
|
||||
|
||||
mock_play.assert_not_called()
|
||||
|
||||
|
||||
class TestPlayFunction:
|
||||
"""WAV playback tests."""
|
||||
|
||||
def test_play_calls_aplay(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import _play
|
||||
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.announce.subprocess.Popen") as mock_popen:
|
||||
_play(mock_path)
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
args = mock_popen.call_args[0][0]
|
||||
assert args[0] == "aplay"
|
||||
assert args[1] == "-q"
|
||||
|
||||
def test_play_skips_when_file_missing(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import _play
|
||||
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = False
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.announce.subprocess.Popen") as mock_popen:
|
||||
_play(mock_path)
|
||||
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
def test_play_graceful_on_os_error(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import _play
|
||||
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.notification.announce.subprocess.Popen",
|
||||
side_effect=OSError("broken"),
|
||||
):
|
||||
_play(mock_path)
|
||||
|
||||
|
||||
class TestSpeakFunction:
|
||||
"""Piper TTS tests."""
|
||||
|
||||
def test_speak_calls_piper_then_aplay(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.PIPER_VOICE") as mock_voice,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.subprocess") as mock_sub,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.tempfile") as mock_tmp,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.Path") as mock_path,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = True
|
||||
mock_voice.exists.return_value = True
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = "/tmp/test.wav"
|
||||
mock_tmp.NamedTemporaryFile.return_value = mock_file
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
mock_path.return_value.exists.return_value = True
|
||||
|
||||
_speak("test text")
|
||||
|
||||
mock_sub.run.assert_called_once()
|
||||
mock_sub.Popen.assert_called_once()
|
||||
|
||||
def test_speak_skips_when_piper_missing(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.subprocess") as mock_sub,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = False
|
||||
_speak("test")
|
||||
|
||||
mock_sub.run.assert_not_called()
|
||||
|
||||
def test_speak_graceful_on_timeout(self):
|
||||
import subprocess as real_sub
|
||||
from aipass.hooks.apps.handlers.notification.announce import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.PIPER_VOICE") as mock_voice,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.subprocess") as mock_sub,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.tempfile") as mock_tmp,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = True
|
||||
mock_voice.exists.return_value = True
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = "/tmp/test.wav"
|
||||
mock_tmp.NamedTemporaryFile.return_value = mock_file
|
||||
mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5)
|
||||
mock_sub.TimeoutExpired = real_sub.TimeoutExpired
|
||||
|
||||
_speak("test")
|
||||
|
||||
def test_speak_graceful_on_os_error(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.PIPER_VOICE") as mock_voice,
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.subprocess.run", side_effect=OSError("broken")),
|
||||
patch("aipass.hooks.apps.handlers.notification.announce.tempfile") as mock_tmp,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = True
|
||||
mock_voice.exists.return_value = True
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = "/tmp/test.wav"
|
||||
mock_tmp.NamedTemporaryFile.return_value = mock_file
|
||||
|
||||
_speak("test")
|
||||
@@ -0,0 +1,427 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_auto_fix.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for auto_fix lifecycle handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/lifecycle/auto_fix.py."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestAutoFixSkips:
|
||||
def test_skip_non_edit_tool(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
result = handle({"tool_name": "Bash", "tool_input": {"command": "ls"}})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_skip_non_code_file_md(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/README.md"}})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_skip_non_code_file_txt(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
result = handle({"tool_name": "Write", "tool_input": {"file_path": "/tmp/notes.txt"}})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_skip_non_code_file_html(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/page.html"}})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
result = handle({})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_missing_file_path(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
result = handle({"tool_name": "Edit", "tool_input": {}})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_skip_unknown_extension(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak"):
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/file.xyz"}})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
|
||||
class TestAutofixPython:
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_python_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}})
|
||||
assert result["exit_code"] == 0
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["systemMessage"] == "[diagnostics] ok"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks")
|
||||
def test_python_syntax_error(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
mock_py.return_value = ["SYNTAX: invalid syntax at line 5"]
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/bad.py"}})
|
||||
assert result["exit_code"] == 0
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "additionalContext" in parsed.get("hookSpecificOutput", {})
|
||||
assert "SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert "1 error(s)" in parsed["systemMessage"]
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks")
|
||||
def test_python_ruff_lint_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
mock_py.return_value = ["LINT: bad.py:10:1: F401 unused import"]
|
||||
result = handle({"tool_name": "Write", "tool_input": {"file_path": "/tmp/bad.py"}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "LINT" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_python_pyright_errors(self, mock_py, mock_ruff_s, mock_seedgo, mock_speak):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check",
|
||||
return_value=[{"line": 42, "message": "Cannot assign to declared type"}],
|
||||
):
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/typed.py"}})
|
||||
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "TYPE: L42" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_seedgo_violations_surfaced(self, mock_py, mock_ruff_s, mock_pyright, mock_speak):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist",
|
||||
return_value=["missing file header"],
|
||||
):
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/noheader.py"}})
|
||||
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "SEEDGO: missing file header" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
|
||||
|
||||
class TestAutoFixStateFile:
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_state_file_written_on_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
mock_ruff_s.return_value = [{"line": 5, "message": "F401: unused import"}]
|
||||
mock_pyright.return_value = [{"line": 10, "message": "Type error here"}]
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf:
|
||||
state_path = Path(tf.name)
|
||||
|
||||
try:
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.STATE_FILE", state_path):
|
||||
handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/errors.py"}})
|
||||
|
||||
assert state_path.exists()
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
assert len(state["errors"]) == 2
|
||||
assert state["errors"][0]["line"] == 5
|
||||
assert state["errors"][1]["line"] == 10
|
||||
finally:
|
||||
if state_path.exists():
|
||||
state_path.unlink()
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_state_file_cleared_on_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as tf:
|
||||
state_path = Path(tf.name)
|
||||
tf.write('{"file": "/tmp/old.py", "errors": [{"line": 1, "message": "old"}]}')
|
||||
|
||||
try:
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.STATE_FILE", state_path):
|
||||
handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}})
|
||||
|
||||
assert not state_path.exists()
|
||||
finally:
|
||||
if state_path.exists():
|
||||
state_path.unlink()
|
||||
|
||||
|
||||
class TestAutoFixJson:
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak")
|
||||
def test_json_valid(self, mock_speak, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
json_file = tmp_path / "good.json"
|
||||
json_file.write_text('{"key": "value"}', encoding="utf-8")
|
||||
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": str(json_file)}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["systemMessage"] == "[diagnostics] ok"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak")
|
||||
def test_json_invalid_syntax(self, mock_speak, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
json_file = tmp_path / "bad.json"
|
||||
json_file.write_text('{"key": }', encoding="utf-8")
|
||||
|
||||
result = handle({"tool_name": "Write", "tool_input": {"file_path": str(json_file)}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "JSON SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak")
|
||||
def test_json_corruption_detected(self, mock_speak, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
json_file = tmp_path / "corrupt.json"
|
||||
json_file.write_text('{"data": "\x00bad"}', encoding="utf-8")
|
||||
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": str(json_file)}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "EMOJI CORRUPTION" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
|
||||
|
||||
class TestAutoFixPiper:
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.PIPER_VOICE")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.PIPER_BIN")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_piper_fires_on_edit(
|
||||
self,
|
||||
mock_popen,
|
||||
mock_run,
|
||||
mock_py,
|
||||
mock_ruff_s,
|
||||
mock_pyright,
|
||||
mock_seedgo,
|
||||
mock_piper_bin,
|
||||
mock_piper_voice,
|
||||
):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
mock_piper_bin.exists.return_value = True
|
||||
mock_piper_voice.exists.return_value = True
|
||||
mock_run_result = MagicMock()
|
||||
mock_run_result.returncode = 0
|
||||
mock_run.return_value = mock_run_result
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.Path") as mock_path_cls:
|
||||
mock_path_cls.return_value.suffix.lower.return_value = ".py"
|
||||
mock_path_cls.return_value.name = "test.py"
|
||||
mock_path_cls.return_value.exists.return_value = True
|
||||
|
||||
handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/test.py"}})
|
||||
|
||||
assert mock_run.called
|
||||
|
||||
def test_piper_skips_when_unavailable(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _speak
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.PIPER_BIN") as mock_bin:
|
||||
mock_bin.exists.return_value = False
|
||||
_speak("test")
|
||||
|
||||
|
||||
class TestAutoFixSubprocessChecks:
|
||||
@patch("subprocess.run")
|
||||
def test_check_syntax_error(self, mock_run):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_syntax
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="SyntaxError: invalid syntax")
|
||||
errors = _check_syntax("/tmp/bad.py")
|
||||
assert len(errors) == 1
|
||||
assert "SYNTAX" in errors[0]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_check_syntax_clean(self, mock_run):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_syntax
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
errors = _check_syntax("/tmp/good.py")
|
||||
assert errors == []
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_check_ruff_lint_findings(self, mock_run):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_ruff_lint
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="bad.py:10:1: F401 unused import\n")
|
||||
errors = _check_ruff_lint("/tmp/bad.py")
|
||||
assert len(errors) == 1
|
||||
assert "LINT" in errors[0]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_check_ruff_format_drift(self, mock_run):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_ruff_format
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
errors = _check_ruff_format("/tmp/unformatted.py")
|
||||
assert len(errors) == 1
|
||||
assert "FORMAT" in errors[0]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_ruff_lint_structured_returns_dicts(self, mock_run):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_ruff_lint_structured
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout=json.dumps(
|
||||
[
|
||||
{"location": {"row": 5}, "code": "F401", "message": "unused import os"},
|
||||
]
|
||||
),
|
||||
)
|
||||
errors = _run_ruff_lint_structured("/tmp/lint.py")
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["line"] == 5
|
||||
assert "F401" in errors[0]["message"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_ruff_lint_structured_skips_claude_hooks(self, mock_run):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_ruff_lint_structured
|
||||
|
||||
errors = _run_ruff_lint_structured("/home/user/.claude/hooks/myhook.py")
|
||||
assert errors == []
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_pyright_check_returns_errors(self, mock_run):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_pyright_check
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout=json.dumps(
|
||||
{
|
||||
"generalDiagnostics": [
|
||||
{
|
||||
"severity": "error",
|
||||
"range": {"start": {"line": 42}},
|
||||
"message": "Cannot assign type",
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"range": {"start": {"line": 10}},
|
||||
"message": "This is a warning",
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
)
|
||||
errors = _run_pyright_check("/tmp/typed.py")
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["line"] == 42
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_pyright_skips_claude_hooks(self, mock_run):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_pyright_check
|
||||
|
||||
errors = _run_pyright_check("/home/user/.claude/hooks/myhook.py")
|
||||
assert errors == []
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_seedgo_checklist_returns_violations(self, mock_run):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_seedgo_checklist
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="✓ file_header: OK\n✗ missing encoding param\n✗ bad import\n",
|
||||
)
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": "/home/user/Projects/AIPass"}):
|
||||
violations = _run_seedgo_checklist("/tmp/check.py")
|
||||
assert len(violations) == 2
|
||||
assert "missing encoding param" in violations[0]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_seedgo_skips_claude_hooks(self, mock_run):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_seedgo_checklist
|
||||
|
||||
violations = _run_seedgo_checklist("/home/user/.claude/hooks/myhook.py")
|
||||
assert violations == []
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_run_seedgo_skips_without_aipass_home(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_seedgo_checklist
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
violations = _run_seedgo_checklist("/tmp/check.py")
|
||||
assert violations == []
|
||||
|
||||
|
||||
class TestAutoFixPatterns:
|
||||
def test_check_line_pattern_matches(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_line_pattern
|
||||
|
||||
assert _check_line_pattern(" logger.debug(msg)", "logger.debug(") is True
|
||||
|
||||
def test_check_line_pattern_skips_comments(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_line_pattern
|
||||
|
||||
assert _check_line_pattern(" # logger.debug(msg)", "logger.debug(") is False
|
||||
|
||||
def test_check_line_pattern_skips_strings(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_line_pattern
|
||||
|
||||
assert _check_line_pattern(' msg = "logger.debug(test)"', "logger.debug(") is False
|
||||
|
||||
def test_check_emoji_list_clean(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_emoji_list
|
||||
|
||||
assert _check_emoji_list(["hello", "world"], "emojis") is None
|
||||
|
||||
def test_check_emoji_list_suspicious(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import _check_emoji_list
|
||||
|
||||
result = _check_emoji_list(["a"], "emojis")
|
||||
assert result is not None
|
||||
assert "EMOJI CORRUPTION" in result
|
||||
@@ -0,0 +1,87 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_auto_watchdog.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for auto_watchdog lifecycle handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-21
|
||||
# Modified: 2026-05-21
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/lifecycle/auto_watchdog.py."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class TestAutoWatchdogHandler:
|
||||
def test_handle_returns_result_dict(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle
|
||||
|
||||
result = handle({"tool_name": "Bash", "tool_input": {"command": "ls"}})
|
||||
assert isinstance(result, dict)
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_dispatch_detected(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": 'drone @ai_mail dispatch @hooks "Subject" "Body"'},
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "additionalContext" in parsed
|
||||
assert "AUTO-WATCHDOG" in parsed["additionalContext"]
|
||||
|
||||
def test_skip_non_bash(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"command": "drone @ai_mail dispatch @hooks"},
|
||||
}
|
||||
)
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_skip_when_watchdog_in_command(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "drone @ai_mail dispatch @hooks && while [ unread_count ]; do sleep 1; done"},
|
||||
}
|
||||
)
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_skip_dispatch_wake_without_target(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "drone @ai_mail dispatch wake"},
|
||||
}
|
||||
)
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_normal_bash_no_dispatch(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "cd /tmp && ls -la"},
|
||||
}
|
||||
)
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle
|
||||
|
||||
result = handle({})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
@@ -0,0 +1,136 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_branch_loader.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for branch_loader prompt handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/prompt/branch_loader.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestBranchLoaderHandler:
|
||||
def test_loads_branch_prompt(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
aipass_dir = tmp_path / ".aipass"
|
||||
aipass_dir.mkdir()
|
||||
prompt = aipass_dir / "aipass_local_prompt.md"
|
||||
prompt.write_text("# Test Branch\nSome instructions", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Branch Context:" in result["stdout"]
|
||||
assert "Some instructions" in result["stdout"]
|
||||
|
||||
def test_loads_private_integrations(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
integration = tmp_path / "apps" / "integrations" / "test_int"
|
||||
integration.mkdir(parents=True)
|
||||
private = integration / "private_prompt.md"
|
||||
private.write_text("# Private Integration\nSecret stuff", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Private Integration" in result["stdout"]
|
||||
|
||||
def test_loads_both_prompt_and_integrations(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
aipass_dir = tmp_path / ".aipass"
|
||||
aipass_dir.mkdir()
|
||||
(aipass_dir / "aipass_local_prompt.md").write_text("Branch prompt", encoding="utf-8")
|
||||
integration = tmp_path / "apps" / "integrations" / "compass"
|
||||
integration.mkdir(parents=True)
|
||||
(integration / "private_prompt.md").write_text("Compass prompt", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Branch prompt" in result["stdout"]
|
||||
assert "Compass prompt" in result["stdout"]
|
||||
|
||||
def test_returns_empty_when_no_branch_root(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_stops_at_repo_root(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
(tmp_path / ".git").mkdir()
|
||||
nested = tmp_path / "some" / "deep" / "path"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"):
|
||||
result = handle({"cwd": str(nested)})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_walks_up_to_find_branch(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
aipass_dir = tmp_path / ".aipass"
|
||||
aipass_dir.mkdir()
|
||||
(aipass_dir / "aipass_local_prompt.md").write_text("Found it", encoding="utf-8")
|
||||
nested = tmp_path / "apps" / "handlers" / "security"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"):
|
||||
result = handle({"cwd": str(nested)})
|
||||
|
||||
assert "Found it" in result["stdout"]
|
||||
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"):
|
||||
with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_no_prompt_file_but_has_branch_root(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_includes_source_path_in_output(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
aipass_dir = tmp_path / ".aipass"
|
||||
aipass_dir.mkdir()
|
||||
(aipass_dir / "aipass_local_prompt.md").write_text("content", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Source:" in result["stdout"]
|
||||
@@ -0,0 +1,88 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_compact.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for compact lifecycle handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/lifecycle/compact.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestCompactHandler:
|
||||
def test_injects_recovery_context(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
local = trinity / "local.json"
|
||||
local.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sessions": [{"id": "S10", "d": "2026-05-22", "sum": "did stuff"}],
|
||||
"key_learnings": {"learn1": "value1"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
status = tmp_path / "STATUS.local.md"
|
||||
status.write_text("# Status\nCurrent work here", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value="Git branch: dev"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "POST-COMPACT RECOVERY" in result["stdout"]
|
||||
assert "Git branch: dev" in result["stdout"]
|
||||
assert "did stuff" in result["stdout"]
|
||||
assert "Current work here" in result["stdout"]
|
||||
|
||||
def test_returns_recovery_when_no_branch_dir(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
result = handle({"cwd": "/tmp/nonexistent"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "POST-COMPACT RECOVERY" in result["stdout"]
|
||||
|
||||
def test_dispatched_agent_gets_save_warning(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "dispatched"}):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "SAVE STATE NOW" in result["stdout"]
|
||||
|
||||
def test_interactive_gets_recovery_protocol(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Recovery Protocol" in result["stdout"]
|
||||
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch("pathlib.Path.cwd", return_value=MagicMock(parts=("/", "tmp"))):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
@@ -0,0 +1,122 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_edit_gate.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for edit_gate security handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-21
|
||||
# Modified: 2026-05-21
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/security/edit_gate.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestEditGateHandler:
|
||||
def test_allow_normal_edit(self):
|
||||
from aipass.hooks.apps.handlers.security.edit_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/apps/test.py"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/hooks",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_block_inbox_write(self):
|
||||
from aipass.hooks.apps.handlers.security.edit_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/.ai_mail.local/inbox.json"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/hooks",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 2
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["decision"] == "block"
|
||||
assert "inbox.json" in parsed["reason"]
|
||||
|
||||
def test_block_cross_branch(self):
|
||||
from aipass.hooks.apps.handlers.security.edit_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/apps/test.py"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 2
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["decision"] == "block"
|
||||
assert "Cross-branch" in parsed["reason"]
|
||||
|
||||
def test_allow_trusted_cross_branch(self):
|
||||
from aipass.hooks.apps.handlers.security.edit_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/apps/test.py"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_block_daemon_cross_branch(self):
|
||||
from aipass.hooks.apps.handlers.security.edit_gate import handle
|
||||
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "daemon"}):
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/apps/test.py"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 2
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "daemon" in parsed["reason"]
|
||||
|
||||
def test_allow_daemon_own_branch(self):
|
||||
from aipass.hooks.apps.handlers.security.edit_gate import handle
|
||||
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "daemon"}):
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/api/apps/test.py"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_skip_non_edit_tool(self):
|
||||
from aipass.hooks.apps.handlers.security.edit_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"file_path": "/home/patrick/Projects/AIPass/src/aipass/hooks/.ai_mail.local/inbox.json"},
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_empty_file_path(self):
|
||||
from aipass.hooks.apps.handlers.security.edit_gate import handle
|
||||
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": ""}})
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.security.edit_gate import handle
|
||||
|
||||
result = handle({})
|
||||
assert result["exit_code"] == 0
|
||||
@@ -0,0 +1,380 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_email.py
|
||||
# Version: 1.1.0
|
||||
# Description: Tests for email notification handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-21
|
||||
# Modified: 2026-05-21
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/notification/email.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestEmailHandler:
|
||||
"""Core handler behavior tests."""
|
||||
|
||||
def test_handle_returns_result_dict(self):
|
||||
from aipass.hooks.apps.handlers.notification.email import handle
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=None,
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_handle_returns_notification_when_new_emails(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import handle
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text(
|
||||
json.dumps({"messages": [{"status": "new", "subject": "test"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.hooks.apps.handlers.notification.email._speak"),
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert "1 new email" in result["stdout"]
|
||||
assert "drone @ai_mail inbox" in result["stdout"]
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_handle_speaks_when_new_emails(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import handle
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text(
|
||||
json.dumps({"messages": [{"status": "new", "subject": "test"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.hooks.apps.handlers.notification.email._speak") as mock_speak,
|
||||
):
|
||||
handle({})
|
||||
|
||||
mock_speak.assert_called_once_with("email notification: 1 new email")
|
||||
|
||||
def test_handle_does_not_speak_when_no_emails(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import handle
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text(
|
||||
json.dumps({"messages": [{"status": "read"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.hooks.apps.handlers.notification.email._speak") as mock_speak,
|
||||
):
|
||||
handle({})
|
||||
|
||||
mock_speak.assert_not_called()
|
||||
|
||||
def test_handle_returns_empty_when_no_new_emails(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import handle
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text(
|
||||
json.dumps({"messages": [{"status": "read", "subject": "old"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.hooks.apps.handlers.notification.email._speak"),
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_handle_plural_for_multiple_emails(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import handle
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"messages": [
|
||||
{"status": "new", "subject": "one"},
|
||||
{"status": "new", "subject": "two"},
|
||||
{"status": "new", "subject": "three"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.hooks.apps.handlers.notification.email._speak"),
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert "3 new emails" in result["stdout"]
|
||||
|
||||
|
||||
class TestCountNewEmails:
|
||||
"""Inbox counting logic tests."""
|
||||
|
||||
def test_counts_new_status(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import _count_new_emails
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"messages": [
|
||||
{"status": "new"},
|
||||
{"status": "new"},
|
||||
{"status": "read"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert _count_new_emails(tmp_path) == 2
|
||||
|
||||
def test_counts_unread_without_status(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import _count_new_emails
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text(
|
||||
json.dumps({"messages": [{"subject": "no status field"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert _count_new_emails(tmp_path) == 1
|
||||
|
||||
def test_skips_read_messages(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import _count_new_emails
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text(
|
||||
json.dumps({"messages": [{"status": "read"}, {"read": True}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert _count_new_emails(tmp_path) == 0
|
||||
|
||||
def test_handles_bare_list_format(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import _count_new_emails
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text(
|
||||
json.dumps([{"status": "new"}, {"status": "read"}]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert _count_new_emails(tmp_path) == 1
|
||||
|
||||
def test_falls_back_to_legacy_path(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import _count_new_emails
|
||||
|
||||
inbox_dir = tmp_path / "ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text(
|
||||
json.dumps({"messages": [{"status": "new"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert _count_new_emails(tmp_path) == 1
|
||||
|
||||
def test_returns_zero_when_no_inbox(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import _count_new_emails
|
||||
|
||||
assert _count_new_emails(tmp_path) == 0
|
||||
|
||||
def test_returns_zero_on_corrupt_json(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import _count_new_emails
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir()
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_file.write_text("not valid json{{{", encoding="utf-8")
|
||||
|
||||
assert _count_new_emails(tmp_path) == 0
|
||||
|
||||
|
||||
class TestFindBranchRoot:
|
||||
"""Branch root discovery tests."""
|
||||
|
||||
def test_finds_branch_with_trinity(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import _find_branch_root
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
apps = tmp_path / "apps"
|
||||
apps.mkdir()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email.Path.cwd",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_repo_root",
|
||||
return_value=tmp_path.parent,
|
||||
),
|
||||
):
|
||||
result = _find_branch_root()
|
||||
|
||||
assert result == tmp_path
|
||||
|
||||
def test_returns_none_when_no_markers(self):
|
||||
from aipass.hooks.apps.handlers.notification.email import _find_branch_root
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email.Path.cwd",
|
||||
return_value=Path("/tmp/bare"),
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_repo_root",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
result = _find_branch_root()
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_at_repo_root(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import _find_branch_root
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email.Path.cwd",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_repo_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
):
|
||||
result = _find_branch_root()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestSpeakFunction:
|
||||
"""Piper TTS tests."""
|
||||
|
||||
def test_speak_calls_piper_then_aplay(self):
|
||||
from aipass.hooks.apps.handlers.notification.email import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.email.PIPER_VOICE") as mock_voice,
|
||||
patch("aipass.hooks.apps.handlers.notification.email.subprocess") as mock_sub,
|
||||
patch("aipass.hooks.apps.handlers.notification.email.tempfile") as mock_tmp,
|
||||
patch("aipass.hooks.apps.handlers.notification.email.Path") as mock_path,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = True
|
||||
mock_voice.exists.return_value = True
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = "/tmp/test.wav"
|
||||
mock_tmp.NamedTemporaryFile.return_value = mock_file
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
mock_path.return_value.exists.return_value = True
|
||||
|
||||
_speak("test text")
|
||||
|
||||
mock_sub.run.assert_called_once()
|
||||
mock_sub.Popen.assert_called_once()
|
||||
|
||||
def test_speak_skips_when_piper_missing(self):
|
||||
from aipass.hooks.apps.handlers.notification.email import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.email.subprocess") as mock_sub,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = False
|
||||
_speak("test")
|
||||
|
||||
mock_sub.run.assert_not_called()
|
||||
|
||||
def test_speak_graceful_on_timeout(self):
|
||||
import subprocess as real_sub
|
||||
from aipass.hooks.apps.handlers.notification.email import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.email.PIPER_VOICE") as mock_voice,
|
||||
patch("aipass.hooks.apps.handlers.notification.email.subprocess") as mock_sub,
|
||||
patch("aipass.hooks.apps.handlers.notification.email.tempfile") as mock_tmp,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = True
|
||||
mock_voice.exists.return_value = True
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = "/tmp/test.wav"
|
||||
mock_tmp.NamedTemporaryFile.return_value = mock_file
|
||||
mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5)
|
||||
mock_sub.TimeoutExpired = real_sub.TimeoutExpired
|
||||
|
||||
_speak("test")
|
||||
|
||||
def test_speak_graceful_on_os_error(self):
|
||||
from aipass.hooks.apps.handlers.notification.email import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.email.PIPER_VOICE") as mock_voice,
|
||||
patch("aipass.hooks.apps.handlers.notification.email.subprocess.run", side_effect=OSError("broken")),
|
||||
patch("aipass.hooks.apps.handlers.notification.email.tempfile") as mock_tmp,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = True
|
||||
mock_voice.exists.return_value = True
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = "/tmp/test.wav"
|
||||
mock_tmp.NamedTemporaryFile.return_value = mock_file
|
||||
|
||||
_speak("test")
|
||||
@@ -0,0 +1,135 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_git_gate.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for git_gate security handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-21
|
||||
# Modified: 2026-05-21
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/security/git_gate.py."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class TestGitGateHandler:
|
||||
def test_block_raw_git(self):
|
||||
from aipass.hooks.apps.handlers.security.git_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "git status"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 2
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["decision"] == "block"
|
||||
assert "drone" in parsed["reason"]
|
||||
|
||||
def test_block_raw_gh(self):
|
||||
from aipass.hooks.apps.handlers.security.git_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "gh pr list"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 2
|
||||
|
||||
def test_allow_drone_git(self):
|
||||
from aipass.hooks.apps.handlers.security.git_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "drone @git status"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_allow_gh_api(self):
|
||||
from aipass.hooks.apps.handlers.security.git_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "gh api repos/owner/repo/pulls"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_block_edit_settings(self):
|
||||
from aipass.hooks.apps.handlers.security.git_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/home/patrick/.claude/settings.json"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 2
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["decision"] == "block"
|
||||
|
||||
def test_allow_edit_settings_from_devpulse(self):
|
||||
from aipass.hooks.apps.handlers.security.git_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/home/patrick/.claude/settings.json"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_block_edit_hooks_dir(self):
|
||||
from aipass.hooks.apps.handlers.security.git_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/home/patrick/Projects/AIPass/.claude/hooks/some_hook.py"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 2
|
||||
|
||||
def test_allow_normal_bash(self):
|
||||
from aipass.hooks.apps.handlers.security.git_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "ls -la"},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_git_in_quoted_string(self):
|
||||
from aipass.hooks.apps.handlers.security.git_gate import handle
|
||||
|
||||
result = handle(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": 'echo "git status"'},
|
||||
"cwd": "/home/patrick/Projects/AIPass/src/aipass/api",
|
||||
}
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.security.git_gate import handle
|
||||
|
||||
result = handle({})
|
||||
assert result["exit_code"] == 0
|
||||
@@ -0,0 +1,64 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_global_loader.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for global_loader prompt handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/prompt/global_loader.py."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestGlobalLoaderHandler:
|
||||
def test_loads_global_prompt(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
||||
|
||||
aipass_dir = tmp_path / ".aipass"
|
||||
aipass_dir.mkdir()
|
||||
prompt = aipass_dir / "aipass_global_prompt.md"
|
||||
prompt.write_text("# AIPass Global\nContext here", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "AIPass Global" in result["stdout"]
|
||||
assert "Context here" in result["stdout"]
|
||||
|
||||
def test_returns_empty_when_no_aipass_home(self):
|
||||
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_returns_empty_when_file_missing(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_empty_hook_data(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
||||
|
||||
aipass_dir = tmp_path / ".aipass"
|
||||
aipass_dir.mkdir()
|
||||
(aipass_dir / "aipass_global_prompt.md").write_text("content", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == "content"
|
||||
@@ -0,0 +1,151 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_identity.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for identity prompt handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/prompt/identity.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
SAMPLE_PASSPORT = {
|
||||
"branch_info": {
|
||||
"branch_name": "devpulse",
|
||||
"path": "src/aipass/devpulse",
|
||||
"email": "unknown",
|
||||
},
|
||||
"identity": {
|
||||
"role": "orchestration_hub",
|
||||
"purpose": "The user's primary AI collaborator",
|
||||
"traits": ["Pragmatic", "Direct"],
|
||||
"what_i_do": ["Plan", "Design", "Debug"],
|
||||
"what_i_dont_do": ["Full rebuilds"],
|
||||
},
|
||||
"principles": ["Fail honestly", "Memory is everything"],
|
||||
}
|
||||
|
||||
|
||||
class TestIdentityHandler:
|
||||
def test_returns_identity_when_passport_found(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "devpulse Identity" in result["stdout"]
|
||||
assert "orchestration_hub" in result["stdout"]
|
||||
assert "Pragmatic" in result["stdout"]
|
||||
|
||||
def test_returns_empty_when_no_passport(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_walks_up_to_find_passport(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8")
|
||||
nested = tmp_path / "apps" / "handlers"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity._speak"):
|
||||
result = handle({"cwd": str(nested)})
|
||||
|
||||
assert "devpulse Identity" in result["stdout"]
|
||||
|
||||
def test_formats_all_fields(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
out = result["stdout"]
|
||||
assert "Path: src/aipass/devpulse" in out
|
||||
assert "Email: unknown" in out
|
||||
assert "Role: orchestration_hub" in out
|
||||
assert "Purpose: The user's primary AI collaborator" in out
|
||||
assert "Do: Plan | Design | Debug" in out
|
||||
assert "Don't: Full rebuilds" in out
|
||||
assert "Principles: Fail honestly * Memory is everything" in out
|
||||
|
||||
def test_handles_minimal_passport(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(json.dumps({"branch_info": {"branch_name": "test"}, "identity": {}}), encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "test Identity" in result["stdout"]
|
||||
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity._speak"):
|
||||
with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_corrupt_passport_json(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text("{broken json", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity._speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@patch("subprocess.run")
|
||||
def test_piper_fires(self, mock_run, mock_popen):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
with patch.object(Path, "exists", return_value=True):
|
||||
handle({"cwd": "/tmp/nonexistent"})
|
||||
|
||||
assert mock_run.called or mock_popen.called
|
||||
|
||||
def test_piper_skips_when_not_available(self):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.PIPER_BIN", Path("/nonexistent/piper")):
|
||||
result = handle({"cwd": "/tmp/nonexistent"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
@@ -0,0 +1,107 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_rollover.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for rollover lifecycle handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/lifecycle/rollover.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestRolloverHandler:
|
||||
def test_no_repo_root_returns_empty(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=None):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_no_overdue_returns_empty(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", return_value=[]):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_overdue_triggers_rollover(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue",
|
||||
return_value=[("devpulse", "local", "21/20 sessions")],
|
||||
):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.lifecycle.rollover._run_rollover", return_value=(True, "ok")
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_check_file_v2_sessions_overdue(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file
|
||||
|
||||
f = tmp_path / "local.json"
|
||||
f.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"document_metadata": {"limits": {"max_sessions": 5}},
|
||||
"sessions": [{"id": i} for i in range(6)],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
overdue, reason = _check_file(f)
|
||||
assert overdue
|
||||
assert "6/5" in reason
|
||||
|
||||
def test_check_file_v2_not_overdue(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file
|
||||
|
||||
f = tmp_path / "local.json"
|
||||
f.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"document_metadata": {"limits": {"max_sessions": 20}},
|
||||
"sessions": [{"id": i} for i in range(5)],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
overdue, _ = _check_file(f)
|
||||
assert not overdue
|
||||
|
||||
def test_check_file_v1_line_count(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file
|
||||
|
||||
f = tmp_path / "obs.json"
|
||||
content = {"document_metadata": {"limits": {"max_lines": 10}}}
|
||||
text = json.dumps(content, indent=2)
|
||||
lines_needed = 10 - text.count("\n")
|
||||
text += "\n" * lines_needed
|
||||
f.write_text(text, encoding="utf-8")
|
||||
|
||||
overdue, reason = _check_file(f)
|
||||
assert overdue
|
||||
assert "lines" in reason
|
||||
|
||||
def test_check_file_missing(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file
|
||||
|
||||
overdue, _ = _check_file(tmp_path / "nonexistent.json")
|
||||
assert not overdue
|
||||
@@ -0,0 +1,177 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_stop_sound.py
|
||||
# Version: 1.1.0
|
||||
# Description: Tests for stop_sound notification handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-20
|
||||
# Modified: 2026-05-20
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/notification/stop_sound.py."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestStopSoundHandler:
|
||||
"""Core handler behavior tests."""
|
||||
|
||||
def test_handle_returns_result_dict(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import handle
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound._play"),
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound._speak"),
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_handle_speaks_stop_sound(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import handle
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound._play"),
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound._speak") as mock_speak,
|
||||
):
|
||||
handle({})
|
||||
|
||||
mock_speak.assert_called_once_with("stop sound")
|
||||
|
||||
def test_handle_does_not_play_wav(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import handle
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound._play") as mock_play,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound._speak"),
|
||||
):
|
||||
handle({})
|
||||
|
||||
mock_play.assert_not_called()
|
||||
|
||||
def test_handle_skips_when_stop_hook_active(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.stop_sound._speak") as mock_speak:
|
||||
result = handle({"stop_hook_active": True})
|
||||
|
||||
mock_speak.assert_not_called()
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
|
||||
class TestPlayFunction:
|
||||
"""WAV playback tests."""
|
||||
|
||||
def test_play_calls_aplay(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import _play
|
||||
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess.Popen") as mock_popen:
|
||||
_play(mock_path)
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
args = mock_popen.call_args[0][0]
|
||||
assert args[0] == "aplay"
|
||||
assert args[1] == "-q"
|
||||
|
||||
def test_play_skips_when_file_missing(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import _play
|
||||
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = False
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess.Popen") as mock_popen:
|
||||
_play(mock_path)
|
||||
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
def test_play_graceful_on_os_error(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import _play
|
||||
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.notification.stop_sound.subprocess.Popen",
|
||||
side_effect=OSError("broken"),
|
||||
):
|
||||
_play(mock_path)
|
||||
|
||||
|
||||
class TestSpeakFunction:
|
||||
"""Piper TTS tests."""
|
||||
|
||||
def test_speak_calls_piper_then_aplay(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_VOICE") as mock_voice,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess") as mock_sub,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.tempfile") as mock_tmp,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.Path") as mock_path,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = True
|
||||
mock_voice.exists.return_value = True
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = "/tmp/test.wav"
|
||||
mock_tmp.NamedTemporaryFile.return_value = mock_file
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
mock_path.return_value.exists.return_value = True
|
||||
|
||||
_speak("test text")
|
||||
|
||||
mock_sub.run.assert_called_once()
|
||||
mock_sub.Popen.assert_called_once()
|
||||
|
||||
def test_speak_skips_when_piper_missing(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess") as mock_sub,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = False
|
||||
_speak("test")
|
||||
|
||||
mock_sub.run.assert_not_called()
|
||||
|
||||
def test_speak_graceful_on_timeout(self):
|
||||
import subprocess as real_sub
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_VOICE") as mock_voice,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess") as mock_sub,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.tempfile") as mock_tmp,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = True
|
||||
mock_voice.exists.return_value = True
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = "/tmp/test.wav"
|
||||
mock_tmp.NamedTemporaryFile.return_value = mock_file
|
||||
mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5)
|
||||
mock_sub.TimeoutExpired = real_sub.TimeoutExpired
|
||||
|
||||
_speak("test")
|
||||
|
||||
def test_speak_graceful_on_os_error(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import _speak
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_VOICE") as mock_voice,
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess.run", side_effect=OSError("broken")),
|
||||
patch("aipass.hooks.apps.handlers.notification.stop_sound.tempfile") as mock_tmp,
|
||||
):
|
||||
mock_piper_bin.exists.return_value = True
|
||||
mock_voice.exists.return_value = True
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = "/tmp/test.wav"
|
||||
mock_tmp.NamedTemporaryFile.return_value = mock_file
|
||||
|
||||
_speak("test")
|
||||
@@ -0,0 +1,149 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_subagent_gate.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for subagent_gate security handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/security/subagent_gate.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from aipass.hooks.apps.handlers.security.subagent_gate import handle
|
||||
|
||||
|
||||
class TestSubagentGateHandler:
|
||||
def test_no_repo_root_allows(self):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._speak"):
|
||||
result = handle({"cwd": "/tmp/nowhere"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_no_modified_files_allows(self):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._speak"):
|
||||
result = handle({"cwd": "/tmp/somewhere"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None)
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._speak")
|
||||
def test_modified_files_no_violations_allows(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
mock_modified.return_value = ["/fake/repo/src/aipass/hooks/apps/test.py"]
|
||||
result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None)
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._speak")
|
||||
def test_violations_blocks(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
mock_modified.return_value = ["/fake/repo/src/aipass/hooks/apps/bad.py"]
|
||||
mock_seedgo.return_value = ["Missing docstring", "No tests"]
|
||||
result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
|
||||
assert result["exit_code"] == 2
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["decision"] == "block"
|
||||
assert "Missing docstring" in parsed["reason"]
|
||||
assert "No tests" in parsed["reason"]
|
||||
assert "bad.py" in parsed["reason"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._speak")
|
||||
def test_skip_claude_hooks_from_modified_files(self, mock_speak, mock_run, tmp_path):
|
||||
|
||||
src = tmp_path / "src" / "aipass" / "hooks"
|
||||
src.mkdir(parents=True)
|
||||
(tmp_path / ".git").mkdir()
|
||||
mock_run.return_value = MagicMock(stdout=" M .claude/hooks/something.py\n", returncode=0)
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=tmp_path):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._get_cwd_branch", return_value="hooks"):
|
||||
from aipass.hooks.apps.handlers.security.subagent_gate import _get_modified_py_files
|
||||
|
||||
files = _get_modified_py_files(str(src), tmp_path)
|
||||
assert files == []
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_skip_claude_hooks_from_seedgo_checks(self, mock_run):
|
||||
from pathlib import Path
|
||||
from aipass.hooks.apps.handlers.security.subagent_gate import _run_seedgo_checklist
|
||||
|
||||
result = _run_seedgo_checklist("/repo/.claude/hooks/gate.py", Path("/repo"))
|
||||
assert result == []
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._speak")
|
||||
def test_readme_accountability_advisory(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
mock_modified.return_value = ["/fake/repo/src/aipass/hooks/apps/clean.py"]
|
||||
mock_readme.return_value = (
|
||||
"Hook files were modified but .claude/hooks/README.md was not updated. "
|
||||
"Consider updating the README to reflect your changes."
|
||||
)
|
||||
result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
|
||||
assert result["exit_code"] == 0
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["decision"] == "allow"
|
||||
assert "README" in parsed["reason"]
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@patch("subprocess.run")
|
||||
def test_piper_fires_when_available(self, mock_run, mock_popen):
|
||||
from pathlib import Path
|
||||
from aipass.hooks.apps.handlers.security.subagent_gate import _speak
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate.PIPER_BIN", Path("/fake/piper")):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate.PIPER_VOICE", Path("/fake/voice.onnx")):
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
_speak("test")
|
||||
mock_popen.assert_called_once()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_piper_skips_when_not_available(self, mock_popen):
|
||||
from pathlib import Path
|
||||
from aipass.hooks.apps.handlers.security.subagent_gate import _speak
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate.PIPER_BIN", Path("/nonexistent/piper")):
|
||||
_speak("test")
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
def test_empty_hook_data_allows(self):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._speak"):
|
||||
result = handle({})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._speak")
|
||||
def test_exception_in_get_modified_allows(self, mock_speak, mock_root, mock_modified):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
mock_modified.side_effect = RuntimeError("subprocess died")
|
||||
result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
@@ -18,20 +18,13 @@ seedgo readme update @branch # README auto-update
|
||||
|
||||
All modules also accept filename: `standards_audit`, `diagnostics_audit`, `readme_update`. Note: `proof`, `proof_query`, `test_map` currently not --help output (known TODO).
|
||||
|
||||
## Hook Ownership
|
||||
## Hook Architecture
|
||||
|
||||
I own hooks. Canonical runtime location `~/.claude/hooks/` (Anthropic global level — hooks must work across all projects, not per-project). Project-level `.claude/` settings only. Inventory:
|
||||
The **hooks branch** (`src/aipass/hooks/`) owns all hook infrastructure — engine, bridge, and 14 native handlers. Seedgo audits hooks via standards but does not own the hook system.
|
||||
|
||||
- **auto_fix_diagnostics.py** (PostToolUse Edit/Write/NotebookEdit) — runs py_compile + ruff + pattern checks + `drone @seedgo checklist` + pyright on edited file, surfaces errors `additionalContext`, saves type errors state file edit gate
|
||||
- **pre_edit_gate.py** (PreToolUse Edit/Write) — blocks edits OTHER files while type error exists current file (branch-scoped — cross-branch edits allowed)
|
||||
- **subagent_stop_gate.py** — SubagentStop gate. Built, NOT wired settings.json 2026-04-14. Runs seedgo checklist all modified .py files, blocks sub-agent stop until clean. DevPass enforcement pattern. DPLAN-0131 discusses wiring.
|
||||
- **branch_prompt_loader.py** (UserPromptSubmit) — injects `.aipass/aipass_local_prompt.md` when CWD branch
|
||||
- **identity_injector.py** (UserPromptSubmit) — injects passport identity block
|
||||
- **email_notification.py** (UserPromptSubmit) — inbox banner
|
||||
- **pre_compact.py** (PreCompact manual+auto) — memory archival prep
|
||||
- Sounds (tool_use, stop, notification) — sound effects. Hook-sounds plugin itself drone's territory.
|
||||
Provider settings route all events through the bridge: `src/aipass/hooks/apps/handlers/bridges/claude.py <Event>:<handler>`. The bridge dispatches to native Python handlers in `hooks/apps/handlers/` (prompt, security, lifecycle, notification categories).
|
||||
|
||||
Three locations drift today: `~/.claude/hooks/` (runtime), `AIPass/.claude/hooks/` (project-level copies), `AIPass/.claude/global_hooks/` (orphaned drift — `auto_fix_diagnostics.py` 35 lines behind). Consolidation part DPLAN-0131.
|
||||
Seedgo's bridge installer (`drone @seedgo bridge install`) manages hook installation to `~/.claude/settings.json`.
|
||||
|
||||
## Apps Layout (extra layer vs standard branch)
|
||||
|
||||
|
||||
+25
-18
@@ -2,7 +2,7 @@
|
||||
|
||||
# Seedgo
|
||||
|
||||
**Purpose:** Standards compliance platform for AIPass. Audits all 11 core agents against 35 code standards + diagnostics, manages bypass rules, runs proof certification, owns the hook system, and provides per-file checklist validation consumed by auto-fix hooks.
|
||||
**Purpose:** Standards compliance platform for AIPass. Audits all 11 core agents against 35 code standards + diagnostics, manages bypass rules, runs proof certification, and provides per-file checklist validation consumed by auto-fix hooks.
|
||||
**Module:** `aipass.seedgo`
|
||||
**Version:** 2.0.0
|
||||
**Created:** 2026-03-05
|
||||
@@ -16,7 +16,7 @@
|
||||
- Score files 0-100 per standard and report violations with actionable details
|
||||
- Manage bypass rules (`.seedgo/bypass.json`) for deliberate exceptions
|
||||
- Run pyright diagnostics across branches for type error detection
|
||||
- Own the hook system: auto-fix diagnostics, edit gate, subagent stop gate, bridge installer
|
||||
- Hook bridge installer (`drone @seedgo bridge install`) for `~/.claude/settings.json` management
|
||||
- Single-file checklist validation against all standards (consumed by PostToolUse auto-fix hook)
|
||||
- Proof certification via proof/proof_query (triplet, plugin integrity, README currency)
|
||||
- Custom function test coverage mapping via test_map
|
||||
@@ -198,24 +198,30 @@ seedgo/
|
||||
|
||||
---
|
||||
|
||||
## Hook Ownership
|
||||
## Hook Architecture
|
||||
|
||||
Seedgo owns the AIPass hook system. Canonical runtime location: `AIPass/.claude/hooks/`.
|
||||
The **hooks branch** (`src/aipass/hooks/`) owns all hook infrastructure — engine, bridge, and 14 native handlers. Seedgo audits hooks via standards but does not own the hook system.
|
||||
|
||||
| Hook | Event | What It Does |
|
||||
|------|-------|--------------|
|
||||
| auto_fix_diagnostics.py v5.2.0 | PostToolUse (Edit/Write) | py_compile + ruff + pattern checks + `drone @seedgo checklist` + pyright. Saves state for edit gate |
|
||||
| pre_edit_gate.py v1.3.0 | PreToolUse (Edit/Write) | Blocks edits to other files while type errors exist. Cross-branch inbox write protection |
|
||||
| subagent_stop_gate.py v1.0 | SubagentStop | Runs checklist on modified .py files, blocks stop until clean |
|
||||
| branch_prompt_loader.py | UserPromptSubmit | Injects `.aipass/aipass_local_prompt.md` when CWD is in a branch |
|
||||
| identity_injector.py | UserPromptSubmit | Injects passport identity block |
|
||||
| email_notification.py | UserPromptSubmit | Inbox banner |
|
||||
| pre_compact.py | PreCompact | Memory archival prep |
|
||||
| notification_sound.py | Notification | Sound effect |
|
||||
| stop_sound.py | Stop | Sound effect |
|
||||
| tool_use_sound.py | PreToolUse | Sound effect |
|
||||
Provider settings route all events through the bridge (`claude.py`), which dispatches to native Python handlers:
|
||||
|
||||
The `bridge` module (`drone @seedgo bridge install`) manages hook installation to `~/.claude/settings.json` using the AIPASS_HOOK_MANIFEST (13 entries across 7 events).
|
||||
| Handler | Event | Category |
|
||||
|---------|-------|----------|
|
||||
| `prompt.global_loader` | UserPromptSubmit | prompt |
|
||||
| `prompt.branch_loader` | UserPromptSubmit | prompt |
|
||||
| `prompt.identity` | UserPromptSubmit | prompt |
|
||||
| `notification.email` | UserPromptSubmit | notification |
|
||||
| `security.edit_gate` | PreToolUse | security |
|
||||
| `security.git_gate` | PreToolUse | security |
|
||||
| `lifecycle.auto_fix` | PostToolUse | lifecycle |
|
||||
| `lifecycle.auto_watchdog` | PostToolUse | lifecycle |
|
||||
| `security.subagent_gate` | SubagentStop | security |
|
||||
| `notification.stop_sound` | Stop | notification |
|
||||
| `notification.announce` | Notification | notification |
|
||||
| `notification.tool_sound` | PreToolUse | notification |
|
||||
| `lifecycle.compact` | PreCompact | lifecycle |
|
||||
| `lifecycle.rollover` | PreCompact | lifecycle |
|
||||
|
||||
The `bridge` module (`drone @seedgo bridge install`) manages hook installation to `~/.claude/settings.json`.
|
||||
|
||||
---
|
||||
|
||||
@@ -247,7 +253,8 @@ The `bridge` module (`drone @seedgo bridge install`) manages hook installation t
|
||||
## Known Issues / Tech Debt
|
||||
|
||||
- `audit_display.py`: 16 hardcoded display blocks for specific standards (DPLAN-0047 tracks dynamic refactor)
|
||||
- Hook location drift: `~/.claude/hooks/` vs `AIPass/.claude/hooks/` (DPLAN-0131)
|
||||
- `bridge_handler.py` AIPASS_HOOK_MANIFEST references old script paths — needs update to bridge architecture
|
||||
- `test_hooks_track_a/b/e.py` import old hook scripts from `.claude/hooks/` — should migrate to test new native handlers
|
||||
- `proof`, `proof_query`, `test_map` not listed in `--help` output
|
||||
- `documentation_check.py` 5-line lookahead limitation for multi-line function signatures
|
||||
- `dead_code_check.py` doesn't recognize `iterdir()` as valid discovery pattern
|
||||
|
||||
@@ -82,7 +82,12 @@ def write_settings(path: Path, data: dict) -> bool:
|
||||
# Hook detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AIPASS_COMMAND_MARKERS = ("AIPass/.claude/hooks/", "$AIPASS_HOME", "aipass_global_prompt")
|
||||
_AIPASS_COMMAND_MARKERS = (
|
||||
"AIPass/.claude/hooks/",
|
||||
"$AIPASS_HOME",
|
||||
"aipass_global_prompt",
|
||||
"aipass/hooks/apps/handlers/bridges/claude.py",
|
||||
)
|
||||
|
||||
|
||||
def is_aipass_hook_entry(entry: dict) -> bool:
|
||||
@@ -111,134 +116,79 @@ def count_aipass_hooks(settings: dict) -> int:
|
||||
# Hook manifest — the canonical set of AIPass hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BRIDGE = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py"
|
||||
|
||||
AIPASS_HOOK_MANIFEST: dict[str, list[dict]] = {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "cat $AIPASS_HOME/.aipass/aipass_global_prompt.md 2>/dev/null || true",
|
||||
}
|
||||
],
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:global_prompt"}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/branch_prompt_loader.py",
|
||||
}
|
||||
],
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:branch_prompt"}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/identity_injector.py",
|
||||
}
|
||||
],
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:identity_injector"}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/email_notification.py",
|
||||
}
|
||||
],
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:email_notification"}],
|
||||
},
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/tool_use_sound.py",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/pre_edit_gate.py",
|
||||
}
|
||||
],
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PreToolUse"}],
|
||||
},
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/auto_fix_diagnostics.py",
|
||||
}
|
||||
],
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PostToolUse"}],
|
||||
},
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/subagent_stop_gate.py",
|
||||
}
|
||||
],
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} SubagentStop"}],
|
||||
},
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/stop_sound.py",
|
||||
}
|
||||
],
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} Stop"}],
|
||||
},
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/notification_sound.py",
|
||||
}
|
||||
],
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} Notification"}],
|
||||
},
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "manual",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/pre_compact.py",
|
||||
"timeout": 60,
|
||||
}
|
||||
],
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact", "timeout": 60}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "auto",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/pre_compact.py",
|
||||
"timeout": 60,
|
||||
}
|
||||
],
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact", "timeout": 60}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "manual",
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact_rollover", "timeout": 120}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "auto",
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact_rollover", "timeout": 120}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,10 +1 @@
|
||||
{
|
||||
"UserPromptSubmit": [
|
||||
{"hooks": [{"type": "command", "command": "python3 .claude/hooks/branch_prompt_loader.py"}]},
|
||||
{"hooks": [{"type": "command", "command": "python3 .claude/hooks/email_notification.py"}]},
|
||||
{"hooks": [{"type": "command", "command": "python3 .claude/hooks/identity_injector.py"}]}
|
||||
],
|
||||
"PreCompact": [
|
||||
{"hooks": [{"type": "command", "command": "python3 .claude/hooks/pre_compact.py"}]}
|
||||
]
|
||||
}
|
||||
{}
|
||||
|
||||
+116
-17
@@ -1,31 +1,130 @@
|
||||
{
|
||||
"UserPromptSubmit": [
|
||||
{"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/global_prompt_loader.py"}]},
|
||||
{"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/branch_prompt_loader.py"}]},
|
||||
{"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/identity_injector.py"}]},
|
||||
{"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/email_notification.py"}]}
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:global_prompt"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/tool_use_sound.py"}]},
|
||||
{"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "hooks": [{"type": "command", "command": "/home/patrick/Projects/AIPass/.venv/bin/python3 /home/patrick/Projects/AIPass/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse"}]},
|
||||
{"matcher": "Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/pre_edit_gate.py"}]},
|
||||
{"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/git_gate.py"}]}
|
||||
{
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{"matcher": "Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/auto_fix_diagnostics.py"}]},
|
||||
{"matcher": "Bash", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/auto_watchdog.py"}]}
|
||||
{
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/subagent_stop_gate.py"}]}
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [],
|
||||
"Notification": [
|
||||
{"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/notification_sound.py"}]}
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact.py", "timeout": 60}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact.py", "timeout": 60}]},
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact_rollover.py", "timeout": 120}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact_rollover.py", "timeout": 120}]}
|
||||
{
|
||||
"matcher": "manual",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "auto",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "manual",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "auto",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -58,17 +58,10 @@ def _normalize_command(cmd: str) -> str:
|
||||
so snapshots are comparable across machines (Linux vs Windows CI).
|
||||
Keeps the interpreter and script name, removes path prefixes.
|
||||
"""
|
||||
# Replace Windows backslashes with forward slashes first
|
||||
cmd = cmd.replace("\\", "/")
|
||||
# Strip any absolute prefix up to and including the repo name
|
||||
# e.g. "python3 /home/patrick/Projects/AIPass/.claude/hooks/x.py"
|
||||
# -> "python3 .claude/hooks/x.py"
|
||||
# e.g. "python3 D:/a/AIPass/AIPass/.claude/hooks/x.py"
|
||||
# -> "python3 .claude/hooks/x.py"
|
||||
cmd = re.sub(r"(?<= )([A-Za-z]:)?/.+?/AIPass/", "", cmd)
|
||||
# Also strip home-dir provider hooks path:
|
||||
# "python3 /home/patrick/.claude/hooks/x.py" -> "python3 .claude/hooks/x.py"
|
||||
cmd = re.sub(r"(?<= )([A-Za-z]:)?/.+?/\.claude/", ".claude/", cmd)
|
||||
cmd = re.sub(r"\$AIPASS_HOME/", "", cmd)
|
||||
cmd = re.sub(r"(?:(?<=^)|(?<= ))([A-Za-z]:)?/.+?/AIPass/", "", cmd)
|
||||
cmd = re.sub(r"(?:(?<=^)|(?<= ))([A-Za-z]:)?/.+?/\.claude/", ".claude/", cmd)
|
||||
return cmd
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user