feat(system): clean hook separation, AIPASS.md as project prompt (#37)

- Move general hooks (sounds, auto-fix) to Anthropic settings only
- Remove duplicated hooks from project settings (don't fire from subdirs)
- Project settings now permissions-only, no hooks
- Identity injector: remove apps/ requirement (fixes devpulse detection)
- Identity injector: support traits[] array from passport
- AIPASS.md: renamed to Project Prompt, silent startup, CWD-relative paths
- /memo: read passport first for identity re-injection
- README: reflects actual working setup with clear Anthropic/project split

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AIPass
2026-03-13 18:02:57 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 08988ecead
commit 4f87b5c044
9 changed files with 72 additions and 443 deletions
+54 -62
View File
@@ -1,83 +1,75 @@
# .claude/ — Claude Code Configuration
This directory configures Claude Code for the AIPass repo. It controls hooks (prompt injection, auto-fix, recovery), custom commands, permissions, and sound effects.
This directory configures Claude Code for the AIPass project.
## How It's Wired
## Settings — Two Files, Clear Separation
There are **two** settings files that matter:
| File | Name | What it does |
|------|------|-------------|
| `~/.claude/settings.json` | **Anthropic settings** | All hooks, sounds, statusline, plugins. Fires everywhere. |
| `.claude/settings.json` (this dir) | **Project settings** | Permissions only. No hooks. |
| File | Scope | Paths |
|------|-------|-------|
| `.claude/settings.json` (this dir) | Project-level, checked into git | Relative paths (e.g. `.claude/hooks/...`) |
| `~/.claude/settings.json` | Global, per-machine | Absolute paths (e.g. `/home/coder/workspace/AIPass/.claude/hooks/...`) |
**Why this split:** Claude Code project settings only fire when launched from the repo root. We launch from branch subdirectories (`src/aipass/{name}/`), so hooks must live in Anthropic settings with absolute paths.
**Why both?** Project settings only fire when Claude launches from the repo root. Since citizens launch from branch subdirectories (`src/aipass/{name}/`), the global config duplicates the hook definitions with absolute paths so they always fire regardless of CWD.
Project settings = permissions. That's it.
After a container rebuild, the global config needs to be restored. The project config is the source of truth (checked into git).
## Hooks — Two Locations, No Duplication
### Anthropic hooks (`~/.claude/hooks/`)
General-purpose scripts that work on any project.
| Script | Event | What it does |
|--------|-------|-------------|
| `tool_use_sound.py` | PreToolUse | Plays keypress sound on tool calls |
| `auto_fix_diagnostics.py` | PostToolUse | Syntax check after file edits |
| `stop_sound.py` | Stop | Sound on stop |
| `notification_sound.py` | Notification | Sound on notification |
### Project hooks (`AIPass/.claude/hooks/`)
AIPass-specific scripts. Referenced from Anthropic settings with absolute paths.
| Script | Event | What it does |
|--------|-------|-------------|
| `branch_prompt_loader.py` | UserPromptSubmit | Finds `.aipass/aipass_local_prompt.md` from CWD, injects branch context |
| `identity_injector.py` | UserPromptSubmit | Reads `.trinity/passport.json`, injects role/traits/purpose |
| `email_notification.py` | UserPromptSubmit | Checks `.ai_mail.local/inbox.json` for unread messages |
| `pre_compact.py` | PreCompact | Saves session context before compaction |
The global prompt (`aipass_global_prompt.md`) is injected via a `cat` command in Anthropic settings — no script needed.
## What Gets Injected Every Turn
1. **Global Prompt** — `cat .aipass/aipass_global_prompt.md` (system context, terminology, rules)
2. **Branch Prompt** — `branch_prompt_loader.py` (branch-specific instructions from CWD)
3. **Identity** — `identity_injector.py` (compact passport summary: role, traits, purpose)
4. **Email** — `email_notification.py` (notification only if unread mail exists)
## Directory Structure
```
.claude/
├── CLAUDE.md # Project instructions for Claude (auto-loaded)
├── settings.json # Project-level hook config + permissions
├── hooks/ # Hook scripts (Python)
│ ├── branch_prompt_loader.py # Injects branch-local system prompt
│ ├── identity_injector.py # Injects citizen identity from passport
│ ├── email_notification.py # Alerts on unread AI Mail
│ ├── auto_fix_diagnostics.py # Auto-fixes Python/JSON issues after edits
│ ├── pre_compact.py # Saves recovery context before compaction
│ ├── tool_use_sound.py # Sound on tool use (needs audio device)
│ ├── notification_sound.py # Sound on notification (needs audio device)
│ └── stop_sound.py # Sound on stop (needs audio device)
├── commands/ # Custom slash commands
│ └── memo.md # /memo — triggers memory update workflow
└── sounds/ # Audio files for sound hooks
├── mixkit-achievement-bell-600.wav
├── mixkit-atm-cash-machine-key-press-2841.wav
└── mixkit-clear-announce-tones-2861.wav
├── settings.json # Permissions only (no hooks)
├── hooks/ # AIPass-specific hook scripts
│ ├── branch_prompt_loader.py
│ ├── identity_injector.py
│ ├── email_notification.py
│ └── pre_compact.py
├── commands/
│ └── memo.md # /memo — memory update workflow
├── sounds/ # Audio files (symlinked from ~/.claude/sounds)
└── README.md
```
## Hooks
## Adding a New Hook
### UserPromptSubmit (fires on every prompt)
1. **Global prompt** — `cat .aipass/aipass_global_prompt.md` — injects system-wide context (terminology, commands, rules)
2. **Branch prompt** — `branch_prompt_loader.py` — finds nearest `.aipass/branch_system_prompt.md` from CWD and injects it
3. **Identity injector** — `identity_injector.py` — reads `.trinity/passport.json` and injects citizen identity (role, purpose, principles)
4. **Email notification** — `email_notification.py` — checks `.ai_mail.local/inbox.json` for unread messages
### PostToolUse (fires after file edits)
5. **Auto-fix diagnostics** — `auto_fix_diagnostics.py` — runs Python syntax check and JSON validation on edited files, outputs fix suggestions
### PreCompact (fires before context compaction)
6. **Pre-compact recovery** — `pre_compact.py` — saves session context so the citizen can recover after compaction
### PreToolUse / Stop / Notification (sound hooks)
7-9. Sound effects — **require audio device** (`--device /dev/snd` in Docker run). Currently non-functional in container.
## Custom Commands
- `/memo` — Triggers memory update workflow. Reads `.trinity/` files, updates local.json, observations.json, and optionally passport.json and README.md.
1. Create the script in `.claude/hooks/`
2. Add one entry to `~/.claude/settings.json` (Anthropic settings) with the absolute path
3. Done — no changes to project settings
## Permissions
Defined in `settings.json`:
- **Denied**: `git reset`, `git rebase`, `git config`, `git push --force`, `EnterPlanMode`
- **Default mode**: `acceptEdits`
## Related Files
- `.aipass/aipass_global_prompt.md` — The global system prompt (at repo root, NOT in .claude/)
- `src/aipass/{name}/.aipass/branch_system_prompt.md` — Per-branch system prompts
- `src/aipass/{name}/.trinity/passport.json` — Citizen identity (read by identity_injector)
- `src/aipass/{name}/.ai_mail.local/inbox.json` — Mailbox (read by email_notification)
## After Container Rebuild
1. `pip install -e . --break-system-packages`
2. Symlink CLI tools: `ln -sf ~/.local/bin/drone /usr/local/bin/drone && ln -sf ~/.local/bin/seedgo /usr/local/bin/seedgo`
3. Copy project settings to global: restore `~/.claude/settings.json` with absolute paths (see global config format above)
+4 -3
View File
@@ -4,9 +4,10 @@ Purpose: Update branch memory files after completing work this session.
## Execution
1. Review what was done this session (context, recent changes, key decisions)
2. Update each file below as needed
3. Confirm completion — list files updated
1. Read `.trinity/passport.json` first — re-absorb your identity, role, and principles before writing memories
2. Review what was done this session (context, recent changes, key decisions)
3. Update each file below as needed
4. Confirm completion — list files updated
## What to Update
-193
View File
@@ -1,193 +0,0 @@
#!/usr/bin/env python3
"""
Silent Auto-fix Hook - Runs validation and tells Claude to fix silently.
Runs real linters (ruff, py_compile), validates JSON, checks Python patterns.
Outputs via additionalContext so Claude sees errors and fixes them without
announcing.
Version: 1.0.0
"""
import json
import sys
import subprocess
from pathlib import Path
EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"]
LAST_FILE_PATH = Path(__file__).parent / ".last_diagnostics_file"
SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"}
PYTHON_PATTERNS = {
"bad_optional": {
"pattern": ": str = None",
"message": "Optional param should use 'str | None = None' pattern",
},
"open_no_encoding": {
"pattern": "open(",
"requires_missing": "encoding=",
"message": "open() without encoding='utf-8'",
},
}
JSON_CORRUPTION_CHARS = ["\ufffd", "\x00"]
def run_python_checks(file_path: str) -> list[str]:
"""Run actual Python validation."""
errors = []
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
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
try:
content = Path(file_path).read_text(encoding="utf-8")
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 content.split("\n"):
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_json_checks(file_path: str) -> list[str]:
"""Run actual JSON validation."""
errors = []
try:
content = Path(file_path).read_text(encoding="utf-8")
for char in JSON_CORRUPTION_CHARS:
if char in content:
errors.append(
f"CORRUPTION: Found corrupted character '{repr(char)}'"
)
break
try:
json.loads(content)
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 should_skip_file(file_path: str) -> bool:
"""Check if file should be skipped."""
if not file_path:
return True
ext = Path(file_path).suffix.lower()
return ext in SKIP_EXTENSIONS
def is_same_file_as_last(file_path: str) -> bool:
"""Smart batching - skip if same file as last check."""
try:
if LAST_FILE_PATH.exists():
last = LAST_FILE_PATH.read_text(encoding="utf-8").strip()
if last == file_path:
return True
LAST_FILE_PATH.write_text(file_path, encoding="utf-8")
except Exception:
pass
return False
def main():
"""Main hook entry point."""
try:
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if tool_name not in EDIT_TOOLS:
return
if should_skip_file(file_path):
return
if is_same_file_as_last(file_path):
return
errors = []
file_type = ""
if file_path.endswith(".py"):
file_type = "Python"
errors = run_python_checks(file_path)
elif file_path.endswith(".json"):
file_type = "JSON"
errors = run_json_checks(file_path)
else:
return
if errors:
error_text = "\n".join(f" - {e}" for e in errors)
context = f"""[SILENT-FIX] {file_type} errors in {Path(file_path).name}:
{error_text}
Fix these issues silently. Do not announce or explain fixes - just fix and continue."""
output = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": context,
},
"systemMessage": f"[diagnostics] {len(errors)} issue(s)",
}
print(json.dumps(output))
else:
output = {"systemMessage": "[diagnostics] ok"}
print(json.dumps(output))
except Exception:
pass
if __name__ == "__main__":
main()
+7 -4
View File
@@ -30,11 +30,10 @@ def find_branch_root() -> Path | None:
search_path = cwd
while search_path >= repo_root:
has_apps = (search_path / "apps").is_dir()
has_trinity = (search_path / ".trinity").is_dir()
has_id = list(search_path.glob("*.id.json"))
if has_apps and (has_trinity or has_id):
if has_trinity or has_id:
return search_path
if search_path == repo_root:
@@ -70,8 +69,12 @@ def format_identity(data: dict) -> str:
identity = data.get("identity", {})
if identity.get("role"):
lines.append(f"Role: {identity['role']}")
if identity.get("traits"):
lines.append(f"Traits: {identity['traits']}")
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']}")
-37
View File
@@ -1,37 +0,0 @@
#!/usr/bin/env python3
"""Notification Hook — Plays sound when AI needs permission."""
import json
import sys
import subprocess
from pathlib import Path
SOUNDS_DIR = Path(__file__).parent.parent / "sounds"
SOUND_FILE = SOUNDS_DIR / "mixkit-clear-announce-tones-2861.wav"
def play_sound() -> None:
if not SOUND_FILE.exists():
return
try:
subprocess.Popen(
["aplay", "-q", str(SOUND_FILE)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
def main():
try:
hook_data = json.loads(sys.stdin.read())
if hook_data.get("hook_event_name") == "Notification":
play_sound()
except Exception:
pass
sys.exit(0)
if __name__ == "__main__":
main()
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
"""Stop Hook — Plays achievement bell when AI finishes responding."""
import json
import sys
import subprocess
from pathlib import Path
SOUNDS_DIR = Path(__file__).parent.parent / "sounds"
SOUND_FILE = SOUNDS_DIR / "mixkit-achievement-bell-600.wav"
def play_sound() -> None:
if not SOUND_FILE.exists():
return
try:
subprocess.Popen(
["aplay", "-q", str(SOUND_FILE)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
def main():
try:
hook_data = json.loads(sys.stdin.read())
if hook_data.get("hook_event_name") == "Stop":
if not hook_data.get("stop_hook_active", False):
play_sound()
except Exception:
pass
sys.exit(0)
if __name__ == "__main__":
main()
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env python3
"""Tool Use Hook — Plays key press sound when AI uses tools."""
import json
import sys
import subprocess
from pathlib import Path
SOUNDS_DIR = Path(__file__).parent.parent / "sounds"
SOUND_FILE = SOUNDS_DIR / "mixkit-atm-cash-machine-key-press-2841.wav"
SOUND_TOOLS = ["Bash", "Edit", "MultiEdit", "Write", "Read", "Grep", "Glob"]
def play_sound() -> None:
if not SOUND_FILE.exists():
return
try:
subprocess.Popen(
["aplay", "-q", str(SOUND_FILE)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
def main():
try:
hook_data = json.loads(sys.stdin.read())
if hook_data.get("hook_event_name") == "PreToolUse":
if hook_data.get("tool_name", "") in SOUND_TOOLS:
play_sound()
except Exception:
pass
sys.exit(0)
if __name__ == "__main__":
main()
+1 -59
View File
@@ -10,63 +10,5 @@
],
"defaultMode": "acceptEdits"
},
"skipDangerousModePermissionPrompt": true,
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/home/patrick/.claude/hook_logger.sh global_prompt && cat /home/patrick/Projects/AIPass/.aipass/aipass_global_prompt.md 2>/dev/null || true"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "/home/patrick/.claude/hook_logger.sh branch_prompt && python3 /home/patrick/Projects/AIPass/.claude/hooks/branch_prompt_loader.py"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "/home/patrick/.claude/hook_logger.sh identity && python3 /home/patrick/Projects/AIPass/.claude/hooks/identity_injector.py"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "/home/patrick/.claude/hook_logger.sh email_check && python3 /home/patrick/Projects/AIPass/.claude/hooks/email_notification.py"
}
]
}
],
"PreCompact": [
{
"matcher": "manual",
"hooks": [
{
"type": "command",
"command": "/home/patrick/.claude/hook_logger.sh pre_compact && python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact.py",
"timeout": 60
}
]
},
{
"matcher": "auto",
"hooks": [
{
"type": "command",
"command": "/home/patrick/.claude/hook_logger.sh pre_compact && python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact.py",
"timeout": 60
}
]
}
]
}
"skipDangerousModePermissionPrompt": true
}
+6 -7
View File
@@ -1,15 +1,14 @@
/home/patrick/Projects/AIPass/CLAUDE.md
# AIPass — Project Prompt
# AIPass — System Prompt
Injected every turn. Keep this lean — details live in README, --help, and .trinity/ memories.
Project-level instructions loaded by Claude Code. Persists in context for the entire conversation.
Details live in README, --help, .trinity/ memories, and the Global Prompt (.aipass/aipass_global_prompt.md).
## Startup
Greetings (`hi`, `hello`, `yo`, `hey`, `sup`, `good morning`, `good evening`, `what's up`) trigger startup protocol. Everything else is a direct task.
On any greeting, silently read these files from CWD and run the commands — no narration, no announcing steps. Just do it and respond with the status.
**On startup, read:** `.trinity/passport.json`, `local.json`, `observations.json`, `DASHBOARD.local.json`, `dev.local.md`, `README..md`
**Then run:** `git status`, `drone systems`
**Read:** `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `STATUS.local.md`, `README.md`
**Run:** `git status`, `drone systems`
## Navigation