From ece29256f41e8010822c6e6f44eb168690ca4368 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 18 May 2026 20:43:30 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20hooks=20branch=20=E2=80=94=20hook=20eng?= =?UTF-8?q?ine=20+=20bridge=20wiring=20(DPLAN-0184=20Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .AIPASS_REGISTRY.lock | 0 .aipass/aipass_global_prompt.md | 8 +- .claude/hooks/engine.jsonl | 79 ++ .claude/hooks/engine.py | 215 ++++++ .claude/hooks/engine_test.log | 10 + .claude/hooks/engine_test_hook.py | 54 ++ .claude/hooks/engine_test_sound.py | 40 + .claude/hooks/pre_compact_rollover.py | 3 +- .claude/sounds/engine_test_beep.wav | Bin 0 -> 26504 bytes .gitignore | 2 +- .../ai_mail/apps/handlers/dispatch/daemon.py | 7 +- .../ai_mail/apps/handlers/dispatch/wake.py | 7 +- .../aipass/apps/handlers/init/bootstrap.py | 121 +-- src/aipass/aipass/tests/test_bootstrap.py | 36 +- src/aipass/devpulse/.seedgo/bypass.json | 30 + src/aipass/hooks/.aipass/README.md | 3 + .../hooks/.aipass/aipass_local_prompt.md | 87 +++ src/aipass/hooks/.claude/README.md | 5 + src/aipass/hooks/.gitignore | 15 + src/aipass/hooks/.seedgo/README.md | 5 + src/aipass/hooks/.seedgo/bypass.json | 119 +++ src/aipass/hooks/README.md | 77 ++ src/aipass/hooks/apps/README.md | 8 + src/aipass/hooks/apps/__init__.py | 2 + src/aipass/hooks/apps/config/__init__.py | 0 src/aipass/hooks/apps/handlers/README.md | 5 + src/aipass/hooks/apps/handlers/__init__.py | 88 +++ .../hooks/apps/handlers/bridges/__init__.py | 0 .../hooks/apps/handlers/bridges/claude.py | 49 ++ .../hooks/apps/handlers/lifecycle/__init__.py | 0 .../apps/handlers/notification/__init__.py | 0 .../hooks/apps/handlers/prompt/__init__.py | 0 .../hooks/apps/handlers/security/__init__.py | 0 src/aipass/hooks/apps/hooks.py | 125 +++ src/aipass/hooks/apps/integrations/README.md | 64 ++ src/aipass/hooks/apps/modules/README.md | 5 + src/aipass/hooks/apps/modules/__init__.py | 0 src/aipass/hooks/apps/modules/engine.py | 226 ++++++ src/aipass/hooks/apps/plugins/README.md | 5 + src/aipass/hooks/apps/plugins/__init__.py | 0 src/aipass/hooks/docs/README.md | 3 + src/aipass/hooks/pytest.ini | 17 + src/aipass/hooks/requirements.project.txt | 3 + src/aipass/hooks/templates/README.md | 5 + src/aipass/hooks/tests/README.md | 6 + src/aipass/hooks/tests/__init__.py | 1 + src/aipass/hooks/tests/conftest.py | 80 ++ src/aipass/hooks/tests/test_engine.py | 730 ++++++++++++++++++ .../memory/apps/handlers/monitor/detector.py | 12 +- .../apps/handlers/rollover/extractor.py | 7 +- .../prax/apps/handlers/dashboard/refresh.py | 19 + .../handlers/dashboard/template_pusher.py | 33 + .../plugins/devpulse_dashboard/refresh.py | 4 +- .../prax/templates/.dashboard_version.json | 14 +- .../builder/.spawn/.template_registry.json | 8 +- .../templates/builder/DASHBOARD.local.json | 33 +- 56 files changed, 2303 insertions(+), 172 deletions(-) create mode 100644 .AIPASS_REGISTRY.lock create mode 100644 .claude/hooks/engine.jsonl create mode 100644 .claude/hooks/engine.py create mode 100644 .claude/hooks/engine_test.log create mode 100644 .claude/hooks/engine_test_hook.py create mode 100644 .claude/hooks/engine_test_sound.py create mode 100644 .claude/sounds/engine_test_beep.wav create mode 100644 src/aipass/hooks/.aipass/README.md create mode 100644 src/aipass/hooks/.aipass/aipass_local_prompt.md create mode 100644 src/aipass/hooks/.claude/README.md create mode 100644 src/aipass/hooks/.gitignore create mode 100644 src/aipass/hooks/.seedgo/README.md create mode 100644 src/aipass/hooks/.seedgo/bypass.json create mode 100644 src/aipass/hooks/README.md create mode 100644 src/aipass/hooks/apps/README.md create mode 100644 src/aipass/hooks/apps/__init__.py create mode 100644 src/aipass/hooks/apps/config/__init__.py create mode 100644 src/aipass/hooks/apps/handlers/README.md create mode 100644 src/aipass/hooks/apps/handlers/__init__.py create mode 100644 src/aipass/hooks/apps/handlers/bridges/__init__.py create mode 100644 src/aipass/hooks/apps/handlers/bridges/claude.py create mode 100644 src/aipass/hooks/apps/handlers/lifecycle/__init__.py create mode 100644 src/aipass/hooks/apps/handlers/notification/__init__.py create mode 100644 src/aipass/hooks/apps/handlers/prompt/__init__.py create mode 100644 src/aipass/hooks/apps/handlers/security/__init__.py create mode 100644 src/aipass/hooks/apps/hooks.py create mode 100644 src/aipass/hooks/apps/integrations/README.md create mode 100644 src/aipass/hooks/apps/modules/README.md create mode 100644 src/aipass/hooks/apps/modules/__init__.py create mode 100644 src/aipass/hooks/apps/modules/engine.py create mode 100644 src/aipass/hooks/apps/plugins/README.md create mode 100644 src/aipass/hooks/apps/plugins/__init__.py create mode 100644 src/aipass/hooks/docs/README.md create mode 100644 src/aipass/hooks/pytest.ini create mode 100644 src/aipass/hooks/requirements.project.txt create mode 100644 src/aipass/hooks/templates/README.md create mode 100644 src/aipass/hooks/tests/README.md create mode 100644 src/aipass/hooks/tests/__init__.py create mode 100644 src/aipass/hooks/tests/conftest.py create mode 100644 src/aipass/hooks/tests/test_engine.py diff --git a/.AIPASS_REGISTRY.lock b/.AIPASS_REGISTRY.lock new file mode 100644 index 00000000..e69de29b diff --git a/.aipass/aipass_global_prompt.md b/.aipass/aipass_global_prompt.md index 4321196b..16ecd8cc 100644 --- a/.aipass/aipass_global_prompt.md +++ b/.aipass/aipass_global_prompt.md @@ -85,9 +85,13 @@ Local files = source of truth. Edit file → state on disk IS reality. Linting and formatting run automatically on commit via drone's commit handler (ruff check --fix + ruff format). -# aipass init +# aipass CLI -Bootstraps AIPass project in any directory, inside or outside repo. Creates registry, identity, memory, local prompt. Any folder becomes AI-powered workspace with persistent memory. Spawn adds full agent scaffolding on top. +`aipass` = standalone binary (`/usr/local/bin/aipass`). User-facing tool — not drone-routed. Users run `aipass` directly without knowing about drone. + +Commands: `aipass init`, `aipass doctor`, `aipass handoff`, `aipass help`, `aipass profile`. Never `drone @aipass` — that's not how it works. + +`aipass init` bootstraps AIPass project in any directory, inside or outside repo. Creates registry, identity, memory, local prompt. Any folder becomes AI-powered workspace with persistent memory. Spawn adds full agent scaffolding on top. Source: `src/aipass/cli/apps/handlers/init/bootstrap.py` diff --git a/.claude/hooks/engine.jsonl b/.claude/hooks/engine.jsonl new file mode 100644 index 00000000..9265eb46 --- /dev/null +++ b/.claude/hooks/engine.jsonl @@ -0,0 +1,79 @@ +{"ts": 1779087885.8874333, "event": "PreToolUse", "hook": "tool_use_sound", "exit_code": 0, "elapsed_ms": 40.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087885.8876748, "event": "PreToolUse", "hook": "pre_edit_gate", "action": "skipped_no_match", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "value": "Read"} +{"ts": 1779087885.8881602, "event": "PreToolUse", "hook": "git_gate", "action": "skipped_no_match", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "value": "Read"} +{"ts": 1779087885.888458, "event": "PreToolUse", "hook": "engine_test_sound", "action": "skipped_no_match", "matcher": "WebSearch", "value": "Read"} +{"ts": 1779087885.9210913, "event": "PreToolUse", "hook": "BROKEN_crash_test", "exit_code": 2, "elapsed_ms": 31.2, "stdout_len": 0, "stderr_preview": "python3: can't open file '/tmp/THIS_DOES_NOT_EXIST_AT_ALL.py': [Errno 2] No such file or directory\n", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087885.9220648, "event": "PreToolUse", "hook": "BROKEN_crash_test", "action": "crashed", "stderr": "python3: can't open file '/tmp/THIS_DOES_NOT_EXIST_AT_ALL.py': [Errno 2] No such file or directory\n"} +{"ts": 1779087885.9231257, "event": "PreToolUse", "action": "complete", "hooks_run": 0, "total_ms": 372.8} +{"ts": 1779087903.771124, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 211.9, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087903.7721746, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 1407.3} +{"ts": 1779087908.359278, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 1317.3, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087908.360058, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 1900.4} +{"ts": 1779087948.5783036, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 53.2, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087948.6398153, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 60.5, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087948.7356682, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 94.9, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087948.8025231, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 66.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087948.8031442, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 592.6} +{"ts": 1779087969.61676, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 83.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087969.6175067, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 549.3} +{"ts": 1779087973.7319121, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 660.8, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087973.7324946, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 927.3} +{"ts": 1779088321.8144712, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 44.0, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088321.8797712, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 62.3, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088321.939397, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 57.8, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088321.9993627, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 59.2, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088322.0001252, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 539.6} +{"ts": 1779088523.355975, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 69.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088523.356537, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 392.2} +{"ts": 1779088557.6554952, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 33.4, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088557.696279, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 39.6, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088557.7499194, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 52.2, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088557.7993498, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 48.2, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088557.8000984, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 995.9} +{"ts": 1779088567.4456015, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 69.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088567.4462054, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 449.2} +{"ts": 1779088570.872307, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 758.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088570.8730876, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 1024.6} +{"ts": 1779088693.5529475, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 44.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088693.6015344, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 47.8, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088693.6411777, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 38.0, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088693.6902359, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 48.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088693.6908083, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 439.1} +{"ts": 1779088702.3629355, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 85.2, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088702.3633838, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 459.4} +{"ts": 1779088706.1254911, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 649.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088706.1261542, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 904.8} +{"ts": 1779122916.2700117, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 41.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122916.270565, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 390.0} +{"ts": 1779122954.4108016, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 97.3, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122954.4598262, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 47.8, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122954.557771, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 97.1, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122954.6079268, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 48.9, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122954.6094744, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 672.6} +{"ts": 1779122967.6779344, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 45.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122967.6787398, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 401.8} +{"ts": 1779123157.5541441, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 624.7, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123157.554982, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 883.3} +{"ts": 1779123163.3793867, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 33.0, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123163.4235747, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 43.1, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123163.4708867, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 46.4, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123163.5132086, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 41.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123163.5137308, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 429.9} +{"ts": 1779123186.0301352, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 50.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123186.0307028, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 342.4} +{"ts": 1779123254.4626336, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 46.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123254.5158958, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 52.1, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123254.5792346, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 62.4, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123254.6368563, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 56.7, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123254.6375203, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 481.4} +{"ts": 1779123520.2690194, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 70.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123520.269594, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 360.6} +{"ts": 1779123580.7943206, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 45.5, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123580.7948642, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 319.3} +{"ts": 1779123705.523997, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 633.8, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123705.5245044, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 904.5} +{"ts": 1779124421.3406193, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 114.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779124421.437293, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 95.5, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779124421.537384, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 99.3, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779124421.654711, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 116.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779124421.6555922, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 1805.7} diff --git a/.claude/hooks/engine.py b/.claude/hooks/engine.py new file mode 100644 index 00000000..4c46bcd3 --- /dev/null +++ b/.claude/hooks/engine.py @@ -0,0 +1,215 @@ +# =================== AIPass ==================== +# Name: engine.py +# Description: Hook Engine — unified dispatcher for all hook events +# Version: 0.3.0 +# Created: 2026-05-17 +# Modified: 2026-05-17 +# ============================================= + +""" +Hook Engine — DPLAN-0184 Phase 1. + +Single entry point for all hook events. Reads per-project config (.aipass/hooks.json), +dispatches to registered hooks, logs everything via prax logger. + +Called from provider settings (must use venv python for prax imports): + $AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/.claude/hooks/engine.py + +Stdin/stdout contract matches the platform's hook interface. +""" + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +AIPASS_HOME = os.environ.get("AIPASS_HOME", "") +LOG_DIR = Path(AIPASS_HOME) / ".claude" / "hooks" if AIPASS_HOME else Path(__file__).parent +LOG_FILE = LOG_DIR / "engine.jsonl" + + +def _find_project_config() -> dict | None: + """Walk up from CWD looking for .aipass/hooks.json.""" + search = Path.cwd() + home = Path.home() + while search != home and search.parent != search: + config = search / ".aipass" / "hooks.json" + if config.exists(): + try: + raw = config.read_text(encoding="utf-8") + if AIPASS_HOME: + raw = raw.replace("$AIPASS_HOME", AIPASS_HOME) + return json.loads(raw) + except (json.JSONDecodeError, OSError) as exc: + logger.error("[hook_engine] bad config %s: %s", config, exc) + return None + search = search.parent + return None + + +def _log(entry: dict) -> None: + """Append a JSONL log entry for detailed diagnostics.""" + try: + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except OSError as exc: + logger.error("[hook_engine] log write failed: %s", exc) + + +def _run_hook(hook_cmd: str, stdin_data: str) -> dict: + """Run a single hook subprocess, capture output and timing.""" + env = os.environ.copy() + start = time.monotonic() + try: + result = subprocess.run( + hook_cmd, + shell=True, + input=stdin_data, + capture_output=True, + text=True, + timeout=30, + env=env, + ) + elapsed_ms = (time.monotonic() - start) * 1000 + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "elapsed_ms": round(elapsed_ms, 1), + } + except subprocess.TimeoutExpired: + elapsed_ms = (time.monotonic() - start) * 1000 + logger.error("[hook_engine] timeout after 30s: %s", hook_cmd) + return {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": round(elapsed_ms, 1)} + except OSError as exc: + elapsed_ms = (time.monotonic() - start) * 1000 + logger.error("[hook_engine] exec error: %s: %s", hook_cmd, exc) + return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)} + + +def _matches(matcher: str, value: str) -> bool: + """Check if a hook's matcher string matches the given value.""" + if not matcher: + return True + return value in matcher.split("|") + + +def dispatch(event_type: str, stdin_data: str, config: dict) -> str: + """Core dispatch — run hooks for event, return merged stdout.""" + if not config.get("hooks_enabled", True): + logger.info("[hook_engine] all hooks disabled") + _log({"ts": time.time(), "event": event_type, "action": "all_hooks_disabled"}) + return "" + + event_hooks = config.get(event_type, {}) + if not event_hooks: + _log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"}) + return "" + + match_value = "" + try: + parsed = json.loads(stdin_data) if stdin_data.strip() else {} + match_value = parsed.get("tool_name", "") or parsed.get("compact_type", "") or parsed.get("type", "") + except json.JSONDecodeError as exc: + logger.warning("[hook_engine] stdin parse error: %s", exc) + + outputs = [] + total_start = time.monotonic() + + for hook_name, hook_def in event_hooks.items(): + if not hook_def.get("enabled", True): + logger.info("[hook_engine] %s.%s skipped (disabled)", event_type, hook_name) + _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_disabled"}) + continue + + command = hook_def.get("command", "") + matcher = hook_def.get("matcher", "") + if not command: + continue + + if matcher and not _matches(matcher, match_value): + _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_no_match", + "matcher": matcher, "value": match_value}) + continue + + result = _run_hook(command, stdin_data) + + logger.info( + "[hook_engine] %s.%s exit=%d out=%db %dms", + event_type, hook_name, result["exit_code"], + len(result["stdout"]), result["elapsed_ms"], + ) + _log({ + "ts": time.time(), + "event": event_type, + "hook": hook_name, + "exit_code": result["exit_code"], + "elapsed_ms": result["elapsed_ms"], + "stdout_len": len(result["stdout"]), + "stderr_preview": result["stderr"][:200] if result["stderr"] else "", + "cwd": str(Path.cwd()), + }) + + if result["exit_code"] == 2: + is_intentional_block = False + try: + decision = json.loads(result["stdout"]) if result["stdout"].strip() else {} + is_intentional_block = decision.get("decision") == "block" + except (json.JSONDecodeError, AttributeError): + pass + + if is_intentional_block: + total_ms = (time.monotonic() - total_start) * 1000 + logger.warning( + "[hook_engine] %s BLOCKED by %s (%dms)", + event_type, hook_name, total_ms, + ) + _log({"ts": time.time(), "event": event_type, "action": "blocked", + "hook": hook_name, "total_ms": round(total_ms, 1)}) + return result["stdout"] + + logger.error( + "[hook_engine] %s.%s CRASHED exit=2: %s", + event_type, hook_name, result["stderr"][:200], + ) + _log({"ts": time.time(), "event": event_type, "hook": hook_name, + "action": "crashed", "stderr": result["stderr"][:200]}) + + if result["stdout"]: + outputs.append(result["stdout"]) + + total_ms = (time.monotonic() - total_start) * 1000 + logger.info("[hook_engine] %s complete: %d hooks %dms", event_type, len(outputs), total_ms) + _log({"ts": time.time(), "event": event_type, "action": "complete", + "hooks_run": len(outputs), "total_ms": round(total_ms, 1)}) + + return "\n".join(outputs) + + +def main() -> None: + """Entry point — receive event type, dispatch, output result.""" + if len(sys.argv) < 2: + sys.stderr.write("Usage: engine.py \n") + sys.exit(1) + + event_type = sys.argv[1] + + stdin_data = "" + if not sys.stdin.isatty(): + stdin_data = sys.stdin.read() + + config = _find_project_config() + if config is None: + config = {"hooks_enabled": True} + + output = dispatch(event_type, stdin_data, config) + if output: + sys.stdout.write(output) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/engine_test.log b/.claude/hooks/engine_test.log new file mode 100644 index 00000000..e2eb6298 --- /dev/null +++ b/.claude/hooks/engine_test.log @@ -0,0 +1,10 @@ +{"ts": 1779086437.4402049, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "agent_id"]} +{"ts": 1779086460.0803485, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["user_prompt"]} +{"ts": 1779086493.5130055, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "hook_event_name"]} +{"ts": 1779086501.5574267, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "effort"]} +{"ts": 1779086534.0177336, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "effort"]} +{"ts": 1779086594.7874434, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "hook_event_name", "message"]} +{"ts": 1779086688.7642086, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "hook_event_name"]} +{"ts": 1779086728.029055, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["tool_name", "tool_input"]} +{"ts": 1779086747.247906, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "effort"]} +{"ts": 1779086820.3323202, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "hook_event_name"]} diff --git a/.claude/hooks/engine_test_hook.py b/.claude/hooks/engine_test_hook.py new file mode 100644 index 00000000..c766e445 --- /dev/null +++ b/.claude/hooks/engine_test_hook.py @@ -0,0 +1,54 @@ +# =================== AIPass ==================== +# Name: engine_test_hook.py +# Description: Test hook for engine POC — proves engine dispatch works +# Version: 0.1.0 +# Created: 2026-05-17 +# Modified: 2026-05-17 +# ============================================= + +""" +Test hook that proves the engine dispatched correctly. +Writes a timestamped entry to engine_test.log and outputs a system reminder. +Safe — no side effects beyond logging. +""" + +import json +import os +import sys +import time +from pathlib import Path + +LOG_FILE = Path(os.environ.get("AIPASS_HOME", "")) / ".claude" / "hooks" / "engine_test.log" + + +def main() -> None: + """Log proof of execution and output a system reminder.""" + stdin_data = "" + if not sys.stdin.isatty(): + stdin_data = sys.stdin.read() + + event_info = {} + try: + if stdin_data.strip(): + event_info = json.loads(stdin_data) + except json.JSONDecodeError as exc: + sys.stderr.write(f"engine_test_hook: parse error: {exc}\n") + + entry = { + "ts": time.time(), + "hook": "engine_test_hook", + "cwd": str(Path.cwd()), + "event_keys": list(event_info.keys())[:5], + } + + try: + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + except OSError as exc: + sys.stderr.write(f"engine_test_hook: log failed: {exc}\n") + + sys.stdout.write("ENGINE_TEST: Hook engine dispatched successfully\n") + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/engine_test_sound.py b/.claude/hooks/engine_test_sound.py new file mode 100644 index 00000000..d453b310 --- /dev/null +++ b/.claude/hooks/engine_test_sound.py @@ -0,0 +1,40 @@ +# =================== AIPass ==================== +# Name: engine_test_sound.py +# Description: Test sound hook for engine POC — plays distinct beep +# Version: 0.2.0 +# Created: 2026-05-17 +# Modified: 2026-05-18 +# ============================================= + +"""Plays a distinct A5 beep to prove the engine dispatched this hook.""" + +import subprocess +import sys +from pathlib import Path + +SOUNDS_DIR = Path(__file__).parent.parent / "sounds" +DEFAULT_SOUND = SOUNDS_DIR / "engine_test_beep.wav" + + +def main() -> None: + """Play the test beep sound. Accepts optional sound file arg.""" + sound = DEFAULT_SOUND + if len(sys.argv) > 1: + candidate = Path(sys.argv[1]) + if candidate.exists(): + sound = candidate + + if not sound.exists(): + return + try: + subprocess.run( + ["aplay", "-q", str(sound)], + timeout=5, check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.TimeoutExpired): + pass + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/pre_compact_rollover.py b/.claude/hooks/pre_compact_rollover.py index 6620bc42..ebf4c4b6 100755 --- a/.claude/hooks/pre_compact_rollover.py +++ b/.claude/hooks/pre_compact_rollover.py @@ -66,7 +66,8 @@ def _check_file(file_path): schema_version = metadata.get("schema_version", "1.0.0") limits = metadata.get("limits", {}) - if schema_version.startswith("2"): + has_v2_limits = any(k in limits for k in ("max_sessions", "max_key_learnings", "max_observations")) + if has_v2_limits: reasons = [] max_sessions = limits.get("max_sessions") if max_sessions is not None: diff --git a/.claude/sounds/engine_test_beep.wav b/.claude/sounds/engine_test_beep.wav new file mode 100644 index 0000000000000000000000000000000000000000..76a4b12e756149252b44c0a3a2cf87f5633ac8e3 GIT binary patch literal 26504 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 EA69XX^Z)<= literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index 2420662b..1a421b81 100644 --- a/.gitignore +++ b/.gitignore @@ -121,4 +121,4 @@ branch_audits/ claude_4_7_transition_notes.md README_ORIGINAL_DISABLED.md *.bak -test/ +test/ \ No newline at end of file diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py index ded83066..063d0dc8 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py @@ -424,7 +424,12 @@ def spawn_agent( # Update lock with real monitor PID lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock" - lock_data = {"pid": monitor_pid, "timestamp": datetime.now().isoformat(), "branch": str(branch_path)} + lock_data = { + "pid": monitor_pid, + "timestamp": datetime.now().isoformat(), + "branch": str(branch_path), + "subject": subject, + } _write_json(lock_file, lock_data) # Track session cycles for rotation diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py index 917179aa..aecdfa41 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py @@ -501,7 +501,12 @@ def wake_branch( # Update lock with real monitor PID lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock" - lock_data = {"pid": monitor_pid, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "branch": str(branch_path)} + lock_data = { + "pid": monitor_pid, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "branch": str(branch_path), + "subject": custom_message or "manual wake", + } with open(lock_file, "w", encoding="utf-8") as f: json.dump(lock_data, f, indent=2) diff --git a/src/aipass/aipass/apps/handlers/init/bootstrap.py b/src/aipass/aipass/apps/handlers/init/bootstrap.py index be128ded..51208e52 100644 --- a/src/aipass/aipass/apps/handlers/init/bootstrap.py +++ b/src/aipass/aipass/apps/handlers/init/bootstrap.py @@ -44,30 +44,20 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc logger = logging.getLogger(__name__) -PROJECT_HOOKS = [ +# Hooks are NOT distributed to projects. All hooks fire from provider +# settings (~/.claude/settings.json), installed by setup.sh. Provider hooks +# use CWD-walking patterns that work from any directory in any project. +# Hook files are shipped as reference copies only (for debugging/inspection). +HOOKS_TO_SHIP = [ "branch_prompt_loader.py", "email_notification.py", "identity_injector.py", "pre_compact.py", -] - -# These are shipped as reference copies but NOT wired in project settings.json -# because PreToolUse/PostToolUse/SubagentStop only fire from provider settings. -PROVIDER_ONLY_HOOKS = [ "auto_fix_diagnostics.py", "pre_edit_gate.py", "subagent_stop_gate.py", ] -HOOKS_TO_SHIP = PROJECT_HOOKS + PROVIDER_ONLY_HOOKS - -HOOK_EVENTS: dict[str, str] = { - "pre_compact.py": "PreCompact", - "branch_prompt_loader.py": "UserPromptSubmit", - "email_notification.py": "UserPromptSubmit", - "identity_injector.py": "UserPromptSubmit", -} - def _ship_hooks(aipass_home: str, target: Path) -> list[str]: """Copy enforcement + injector hooks from AIPass install to target project. @@ -155,31 +145,31 @@ def _hook_fingerprint(hook_entry: dict) -> str: def _merge_settings(existing: dict, generated: dict) -> dict: """Merge AIPass-generated settings with existing user settings. - Strategy: for each event type, build a set of fingerprints from the - generated (AIPass) hooks. Keep any existing hook whose fingerprint - does NOT appear in the generated set — those are user-added. - Replace all AIPass hooks with the latest generated versions. + Hooks are no longer distributed to projects (provider handles them). + On update, strip any previously-injected AIPass hooks from project + settings while preserving genuine user hooks. """ merged = {} + _aipass_hook_markers = ( + ".claude/hooks/", + "aipass_global_prompt.md", + "aipass_local_prompt.md", + ) + existing_hooks = existing.get("hooks", {}) - generated_hooks = generated.get("hooks", {}) - - merged_hooks: dict[str, list] = {} - all_events = set(existing_hooks.keys()) | set(generated_hooks.keys()) - - for event in all_events: - existing_entries = existing_hooks.get(event, []) - generated_entries = generated_hooks.get(event, []) - - # Fingerprint all generated hooks for this event - generated_fps = {_hook_fingerprint(e) for e in generated_entries} - - # Keep existing hooks that DON'T match any generated hook - user_entries = [e for e in existing_entries if _hook_fingerprint(e) not in generated_fps] - merged_hooks[event] = generated_entries + user_entries - - merged["hooks"] = merged_hooks + if existing_hooks: + cleaned_hooks: dict[str, list] = {} + for event, entries in existing_hooks.items(): + user_entries = [] + for entry in entries: + fp = _hook_fingerprint(entry) + if not any(marker in fp for marker in _aipass_hook_markers): + user_entries.append(entry) + if user_entries: + cleaned_hooks[event] = user_entries + if cleaned_hooks: + merged["hooks"] = cleaned_hooks # Merge env: generated wins for AIPASS_HOME, preserve user additions existing_env = existing.get("env", {}) @@ -213,61 +203,22 @@ def _merge_settings(existing: dict, generated: dict) -> dict: def _claude_settings(aipass_home: str | None = None) -> str: - """Generate .claude/settings.json — hooks for prompt injection at project level. + """Generate .claude/settings.json — env and permissions only. - Only wires hooks that fire from project-level settings: - - UserPromptSubmit: global/local prompt injection + branch_prompt_loader, - email_notification, identity_injector - - PreCompact: pre_compact + Hooks are NOT wired at the project level. All AIPass hooks + (prompt injection, identity, email, pre-compact, edit gates) fire + from provider settings (~/.claude/settings.json), installed by + setup.sh. Provider hooks use CWD-walking patterns that work from + any directory in any project. - PreToolUse/PostToolUse/SubagentStop hooks are NOT wired here — they only - fire from provider settings (~/.claude/settings.json). The scripts are - still shipped as reference copies. Provider wiring is handled by setup.sh. + Project settings only contain: + - env.AIPASS_HOME (so hooks can find the AIPass installation) + - permissions.deny (basic safety rails) Args: aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME. """ - _local_prompt_cmd = ( - 'python3 -c "' - "from pathlib import Path; " - "p=next((x/'.aipass'/'aipass_local_prompt.md' " - "for x in [Path.cwd(),*Path.cwd().parents] " - "if (x/'.aipass'/'aipass_local_prompt.md').exists()),None); " - "p and print(p.read_text(encoding='utf-8'),end='')" - '"' - ) - - event_hooks: dict[str, list] = {} - for hook_name, event in HOOK_EVENTS.items(): - entry = { - "matcher": "", - "hooks": [{"type": "command", "command": f"python3 .claude/hooks/{hook_name}"}], - } - event_hooks.setdefault(event, []).append(entry) - - prompt_hooks = [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "cat .aipass/aipass_global_prompt.md 2>/dev/null || true", - } - ], - }, - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": _local_prompt_cmd, - } - ], - }, - ] - event_hooks["UserPromptSubmit"] = prompt_hooks + event_hooks.get("UserPromptSubmit", []) - - data: dict = {"hooks": event_hooks} + data: dict = {} data["permissions"] = { "deny": [ diff --git a/src/aipass/aipass/tests/test_bootstrap.py b/src/aipass/aipass/tests/test_bootstrap.py index d54b721c..61fb4f65 100644 --- a/src/aipass/aipass/tests/test_bootstrap.py +++ b/src/aipass/aipass/tests/test_bootstrap.py @@ -273,7 +273,7 @@ def test_init_project_gitignore_content(tmp_path): def test_init_project_claude_settings_content(tmp_path): - """.claude/settings.json has valid hook configuration.""" + """.claude/settings.json has env and permissions, no hooks.""" target = tmp_path / "proj" target.mkdir() @@ -281,12 +281,13 @@ def test_init_project_claude_settings_content(tmp_path): settings_path = target / ".claude" / "settings.json" data = json.loads(settings_path.read_text(encoding="utf-8")) - assert "hooks" in data - assert "UserPromptSubmit" in data["hooks"] + assert "hooks" not in data, "Hooks should not be in project settings — provider handles them" + assert "permissions" in data + assert "deny" in data["permissions"] -def test_init_project_settings_has_all_hooks(tmp_path): - """.claude/settings.json wires project-compatible hook event types.""" +def test_init_project_settings_no_hooks(tmp_path): + """.claude/settings.json has no hooks — all hooks fire from provider level.""" target = tmp_path / "proj" target.mkdir() @@ -295,21 +296,9 @@ def test_init_project_settings_has_all_hooks(tmp_path): settings_path = target / ".claude" / "settings.json" data = json.loads(settings_path.read_text(encoding="utf-8")) - # UserPromptSubmit: 2 prompt injectors + 3 hook files - ups_hooks = data["hooks"]["UserPromptSubmit"] - assert len(ups_hooks) == 5, f"Expected 5 UserPromptSubmit hooks, got {len(ups_hooks)}" - assert "aipass_global_prompt.md" in ups_hooks[0]["hooks"][0]["command"] - assert "aipass_local_prompt.md" in ups_hooks[1]["hooks"][0]["command"] - assert "branch_prompt_loader.py" in ups_hooks[2]["hooks"][0]["command"] - - # PreCompact fires from project level - assert len(data["hooks"]["PreCompact"]) == 1 - assert "pre_compact.py" in data["hooks"]["PreCompact"][0]["hooks"][0]["command"] - - # PreToolUse, PostToolUse, Stop are provider-only — NOT in project settings - assert "PreToolUse" not in data["hooks"] - assert "PostToolUse" not in data["hooks"] - assert "Stop" not in data["hooks"] + assert "hooks" not in data, "Project settings should not contain hooks" + assert "env" in data + assert "permissions" in data def test_init_project_global_prompt_content(tmp_path): @@ -763,16 +752,15 @@ def test_init_project_hooks_idempotent_on_rerun(tmp_path): assert len(hook_paths_rerun) == 0 -def test_init_project_settings_has_all_event_types(tmp_path): - """settings.json contains only project-compatible hook event types.""" +def test_init_project_settings_has_no_hook_events(tmp_path): + """settings.json contains no hooks — provider handles all events.""" target = tmp_path / "proj" target.mkdir() init_project(target, project_name="events") settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8")) - expected_events = {"UserPromptSubmit", "PreCompact"} - assert set(settings["hooks"].keys()) == expected_events + assert "hooks" not in settings # --------------------------------------------------------------------------- diff --git a/src/aipass/devpulse/.seedgo/bypass.json b/src/aipass/devpulse/.seedgo/bypass.json index 0ba604b3..ea2de42c 100644 --- a/src/aipass/devpulse/.seedgo/bypass.json +++ b/src/aipass/devpulse/.seedgo/bypass.json @@ -54,6 +54,36 @@ "standard": "debug_print", "file": "tools/spot_check.py", "reason": "Standalone CLI tool — print() is the intended output method." + }, + { + "standard": "silent_catch", + "file": "tools/hook_engine_poc/engine.py", + "reason": "POC hook engine — stdlib only, no prax logger. Uses sys.stderr for error reporting." + }, + { + "standard": "error_handling", + "file": "tools/hook_engine_poc/engine.py", + "reason": "POC hook engine — exception handlers write to stderr, not prax logger." + }, + { + "standard": "silent_catch", + "file": "tools/hook_engine_poc/test_engine.py", + "reason": "POC test harness — catches test exceptions to report pass/fail, writes to stderr." + }, + { + "standard": "imports", + "file": "tools/hook_engine_poc/test_engine.py", + "reason": "POC test harness — sys.path needed to import engine.py from same directory." + }, + { + "standard": "trigger", + "file": "tools/hook_engine_poc/test_engine.py", + "reason": "POC test harness — .unlink() clears ephemeral JSONL log between tests, not a tracked file." + }, + { + "standard": "help_text", + "file": "tools/hook_engine_poc/test_engine.py", + "reason": "POC test harness — usage example in docstring." } ], "notes": { diff --git a/src/aipass/hooks/.aipass/README.md b/src/aipass/hooks/.aipass/README.md new file mode 100644 index 00000000..6504ae58 --- /dev/null +++ b/src/aipass/hooks/.aipass/README.md @@ -0,0 +1,3 @@ +# Branch Prompt + +AI context for `HOOKS`. The `aipass_local_prompt.md` file is injected every turn, telling the AI who you are and how to work in your branch. diff --git a/src/aipass/hooks/.aipass/aipass_local_prompt.md b/src/aipass/hooks/.aipass/aipass_local_prompt.md new file mode 100644 index 00000000..b6e0a91e --- /dev/null +++ b/src/aipass/hooks/.aipass/aipass_local_prompt.md @@ -0,0 +1,87 @@ +# HOOKS — Branch Prompt + + + +*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories, 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}. + +## 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?"* + +- {Primary responsibility} +- {Secondary responsibility} +- {What you build/maintain/operate} + +## 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 +``` + +## 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 +``` + +## 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} + +## 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} + +## 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} diff --git a/src/aipass/hooks/.claude/README.md b/src/aipass/hooks/.claude/README.md new file mode 100644 index 00000000..c9e0e521 --- /dev/null +++ b/src/aipass/hooks/.claude/README.md @@ -0,0 +1,5 @@ +# Claude Code Settings + +Claude Code configuration for `HOOKS`. + +Contains `settings.local.json` with permission rules. Most branches are denied raw git commands and must use `drone @git` instead. diff --git a/src/aipass/hooks/.gitignore b/src/aipass/hooks/.gitignore new file mode 100644 index 00000000..e0a8e539 --- /dev/null +++ b/src/aipass/hooks/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv/ +*.egg-info/ +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ +dist/ +build/ +*.log +*.tmp +*.swp diff --git a/src/aipass/hooks/.seedgo/README.md b/src/aipass/hooks/.seedgo/README.md new file mode 100644 index 00000000..83ae2364 --- /dev/null +++ b/src/aipass/hooks/.seedgo/README.md @@ -0,0 +1,5 @@ +# Standards Bypass + +Seedgo audit bypass config for `HOOKS`. + +When an audit flags a false positive that doesn't apply to your architecture, add a bypass entry in `bypass.json` with a reason explaining why it's justified. diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json new file mode 100644 index 00000000..e26c6beb --- /dev/null +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -0,0 +1,119 @@ +{ + "metadata": { + "version": "1.0.0", + "created": "2026-05-18", + "description": "Standards bypass configuration for this branch" + }, + "bypass": [ + { + "file": "apps/modules/engine.py", + "standard": "modules", + "reason": "Internal dispatch module called by bridges, not a drone-routable command module" + }, + { + "file": "apps/modules/engine.py", + "standard": "introspection", + "reason": "Internal dispatch module, not discoverable via drone @hooks" + }, + { + "file": "apps/modules/engine.py", + "standard": "json_structure", + "reason": "Engine has its own JSONL diagnostic logging, does not use branch json_handler" + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "handlers", + "reason": "Bridges are designed to import engine module — that is their entire purpose" + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "json_structure", + "reason": "Bridge is a thin entry point, no JSON operations to log" + }, + { + "file": "apps/hooks.py", + "standard": "cli", + "reason": "Drone-routed entry point, output via sys.stdout.write to drone subprocess capture, not CLI" + }, + { + "file": "apps/hooks.py", + "standard": "cli_flags", + "reason": "Drone-routed entry point, --version handled in main(), not a standalone CLI binary" + }, + { + "file": "tests/conftest.py", + "standard": "architecture", + "reason": "Test fixtures live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_engine.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_engine.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_engine.py", + "standard": "help_text", + "reason": "Test data contains command references as part of test fixtures, not user-facing help" + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "dead_code", + "reason": "Bridge called from provider settings (external subprocess), not internal imports" + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "architecture", + "reason": "Bridge intentionally imports engine module — bridges exist to call the engine" + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "imports", + "reason": "Bridge imports engine module by design — that is its sole purpose" + }, + { + "file": "apps/hooks.py", + "standard": "unused_function", + "reason": "print_introspection() called by drone's discovery system, not internal code" + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "dead_code", + "reason": "Bridge called from provider settings (external subprocess), not imported internally" + }, + { + "file": "handlers/bridges/claude.py", + "standard": "dead_code", + "reason": "Bridge called externally by provider settings subprocess — no internal import" + }, + { + "file": "tests/test_engine.py", + "standard": "json_handler", + "reason": "Hooks branch does not use json_handler — has its own JSONL logging" + }, + { + "file": "tests/test_engine.py", + "standard": "exception_contracts", + "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns" + } + ], + "notes": { + "usage": "Add entries to bypass specific seedgo standard violations", + "example": { + "file": "apps/example.py", + "standard": "imports", + "reason": "Legacy import required for compatibility" + }, + "fields": { + "file": "Relative path to the file", + "standard": "Which standard to bypass (imports, cli, naming, etc.)", + "lines": "Optional array of line numbers", + "pattern": "Optional regex pattern to match", + "reason": "Why this bypass exists" + } + } +} diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md new file mode 100644 index 00000000..95a09dfa --- /dev/null +++ b/src/aipass/hooks/README.md @@ -0,0 +1,77 @@ +[← Back to AIPass](../../../README.md) + +# Hooks + +> Hook infrastructure for AIPass. Single engine dispatches all hooks across platforms (Claude, Codex, Gemini) with per-project config, full logging, and testability. The 13th citizen. + +Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (.aipass/hooks.json), dispatches matching handlers, and logs everything to prax + JSONL. + +## Start here + +| You want to | Read | +|---|---| +| What's happening right now | [STATUS.local.md](STATUS.local.md) | +| Identity, memory, session history | [`.trinity/`](.trinity/) | +| Hook engine design | `DPLAN-0184` | +| Per-project config | `.aipass/hooks.json` | + +## Commands + +| Command | What it does | +|---|---| +| `drone @hooks status` | Show hook config for current project | +| `drone @hooks log` | Tail recent hook activity (last 20 JSONL entries) | +| `drone @hooks test` | Run hook test suite (planned) | +| `drone @hooks --help` | Full help reference | +| `drone @hooks --version` | Version info | + +## Architecture + +``` +src/aipass/hooks/ +├── .trinity/ # Identity & memory +├── apps/ +│ ├── hooks.py # Entry point (drone @hooks) +│ ├── modules/ +│ │ └── engine.py # Core dispatch — routes events to handlers +│ ├── handlers/ +│ │ ├── bridges/ # One per provider (thin normalization) +│ │ │ └── claude.py # Claude Code bridge +│ │ ├── prompt/ # Prompt injection hooks +│ │ ├── security/ # Enforcement hooks (edit gate, git gate) +│ │ ├── lifecycle/ # Session hooks (compact, stop, subagent) +│ │ └── notification/ # Sound/alert hooks +│ └── config/ # hooks.json validation +├── logs/ +│ └── engine.jsonl # JSONL diagnostics (every hook execution) +├── tests/ +└── STATUS.local.md +``` + +## How It Works + +1. Provider settings have ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`) +2. Bridge calls `engine.dispatch(event_type, stdin_data, config)` +3. Engine reads `.aipass/hooks.json` (walks up from CWD) +4. Engine runs matching hooks sequentially, logs each one +5. First hook returning `{"decision": "block"}` with exit code 2 = bail (block the action) +6. Exit code 2 without JSON = crash (log error, continue to next hook) +7. All hook stdout concatenated and returned to platform + +## Integration Points + +### Depends On + +| Branch | What for | +|---|---| +| prax | Logging (system_logger for prax monitor visibility) | + +### Provides To + +All branches via hook dispatch. Every Claude Code session routes through the engine. + +*Last Updated: 2026-05-18* + +--- + +[← Back to AIPass](../../../README.md) diff --git a/src/aipass/hooks/apps/README.md b/src/aipass/hooks/apps/README.md new file mode 100644 index 00000000..7af3befe --- /dev/null +++ b/src/aipass/hooks/apps/README.md @@ -0,0 +1,8 @@ +# Apps + +Application layer for `HOOKS`. + +- `hooks.py` — Entry point. Auto-discovers and routes commands to modules. +- `modules/` — Business logic and orchestration. One module per command. +- `handlers/` — Implementation details. Called by modules, never by CLI directly. +- `plugins/` — Scheduled tasks and extensions. diff --git a/src/aipass/hooks/apps/__init__.py b/src/aipass/hooks/apps/__init__.py new file mode 100644 index 00000000..aab6abaa --- /dev/null +++ b/src/aipass/hooks/apps/__init__.py @@ -0,0 +1,2 @@ +# HOOKS apps package +from . import handlers # noqa: F401 diff --git a/src/aipass/hooks/apps/config/__init__.py b/src/aipass/hooks/apps/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/README.md b/src/aipass/hooks/apps/handlers/README.md new file mode 100644 index 00000000..8343ca11 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/README.md @@ -0,0 +1,5 @@ +# Handlers + +Implementation details for `HOOKS`. + +Handlers do the actual work. They are called by modules, never directly by the CLI. Keep business logic in modules, implementation in handlers. diff --git a/src/aipass/hooks/apps/handlers/__init__.py b/src/aipass/hooks/apps/handlers/__init__.py new file mode 100644 index 00000000..fdf840d9 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/__init__.py @@ -0,0 +1,88 @@ +"""HOOKS handlers package - Security protected.""" + +import inspect +from pathlib import Path + +MY_BRANCH = "aipass.hooks" + + +def _find_real_caller(): + """Walk the stack to find the actual file that triggered this import. + + Skips this file, importlib internals, and frozen modules. + Returns tuple: (file_path, import_line) or (None, None). + """ + stack = inspect.stack() + this_file = str(Path(__file__).resolve()) + + for frame_info in stack: + filename = frame_info.filename + + if this_file in str(Path(filename).resolve()): + continue + + if filename.startswith("<") or "importlib" in filename: + continue + + import_line = None + if frame_info.code_context: + import_line = frame_info.code_context[0].strip() + + return str(Path(filename).resolve()), import_line + + return None, None + + +def _extract_branch_name(filepath: str) -> str: + """Extract branch name from a file path.""" + parts = Path(filepath).parts + for i, part in enumerate(parts): + if part == "aipass": + if i + 1 < len(parts): + return parts[i + 1] + return "unknown" + + +def _guard_branch_access(): + """Block cross-branch handler imports. + + Only code from within the 'hooks' branch can import these handlers. + External branches must use aipass.hooks.apps.modules instead. + """ + caller_file, import_line = _find_real_caller() + + if caller_file is None: + stack = inspect.stack() + for frame in stack: + if frame.filename in ("", ""): + return + return + + branch_path = "/" + MY_BRANCH.replace(".", "/") + "/" + if branch_path in caller_file.replace("\\", "/"): + return + + caller_branch = _extract_branch_name(caller_file) + caller_filename = Path(caller_file).name + blocked_import = import_line if import_line else "unknown" + + raise ImportError( + f"\n{'=' * 60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'=' * 60}\n" + f" Caller branch: {caller_branch}\n" + f" Caller file: {caller_filename}\n" + f" Blocked: {blocked_import}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" For full standards guide:\n" + f" drone @seedgo handlers\n" + f"{'=' * 60}" + ) + + +# Run guard at import time +_guard_branch_access() diff --git a/src/aipass/hooks/apps/handlers/bridges/__init__.py b/src/aipass/hooks/apps/handlers/bridges/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/bridges/claude.py b/src/aipass/hooks/apps/handlers/bridges/claude.py new file mode 100644 index 00000000..d5a9bd51 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/bridges/claude.py @@ -0,0 +1,49 @@ +# =================== AIPass ==================== +# Name: claude.py +# Version: 1.0.0 +# Description: Claude Code bridge — entry point for provider hook settings +# Branch: hooks +# Layer: apps/handlers/bridges +# Created: 2026-05-18 +# Modified: 2026-05-18 +# ============================================= + +""" +Claude Code bridge. + +Thin entry point called from ~/.claude/settings.json hook entries. +Normalizes Claude Code's stdin/stdout format and calls the engine. + +Called from provider settings as the sole hook entry point per event type. +""" + +import sys + +from aipass.hooks.apps.modules.engine import dispatch, find_project_config +from aipass.prax.apps.modules.logger import system_logger as logger + + +def main() -> None: + """Entry point — receive event type from Claude Code, dispatch via engine.""" + if len(sys.argv) < 2: + sys.stderr.write("Usage: claude.py \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} + logger.info("[HOOKS:claude] no project config found, using defaults") + + output = dispatch(event_type, stdin_data, config) + if output: + sys.stdout.write(output) + + +if __name__ == "__main__": + main() diff --git a/src/aipass/hooks/apps/handlers/lifecycle/__init__.py b/src/aipass/hooks/apps/handlers/lifecycle/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/notification/__init__.py b/src/aipass/hooks/apps/handlers/notification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/prompt/__init__.py b/src/aipass/hooks/apps/handlers/prompt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/security/__init__.py b/src/aipass/hooks/apps/handlers/security/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/hooks.py b/src/aipass/hooks/apps/hooks.py new file mode 100644 index 00000000..1cbe2f68 --- /dev/null +++ b/src/aipass/hooks/apps/hooks.py @@ -0,0 +1,125 @@ +# =================== AIPass ==================== +# Name: hooks.py +# Version: 1.0.0 +# Description: Hook infrastructure — drone entry point +# Branch: hooks +# Layer: apps +# Created: 2026-05-18 +# Modified: 2026-05-18 +# ============================================= + +""" +HOOKS Branch — Hook infrastructure for AIPass. + +Owns all hook dispatch via engine.py. Platform bridges (Claude, Codex, Gemini) +call the engine, which reads per-project config and routes to handlers. +""" + +import os +import sys +from pathlib import Path + +os.environ.setdefault("AIPASS_BRANCH_NAME", "hooks") + +from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402 + + +def print_help() -> None: + """Print hook system help.""" + logger.info("[HOOKS] help requested") + sys.stdout.write( + "HOOKS - Hook infrastructure for AIPass\n" + "\n" + "USAGE:\n" + " drone @hooks status Show hook config for current project\n" + " drone @hooks log Tail recent hook activity\n" + " drone @hooks test Run hook test suite\n" + " drone @hooks --help This help\n" + " drone @hooks --version Version info\n" + "\n" + "MODULES:\n" + " engine Core dispatch — routes events to handlers via config\n" + "\n" + "BRIDGES:\n" + " claude Claude Code bridge (provider settings entry point)\n" + " codex Codex bridge (planned)\n" + " gemini Gemini bridge (planned)\n" + ) + + +def print_introspection() -> dict: + """Return branch introspection data for drone discovery.""" + return { + "branch": "hooks", + "role": "hook_infrastructure", + "commands": ["status", "log", "test"], + "modules": ["engine"], + "bridges": ["claude"], + } + + +def handle_command(command: str, args: list) -> bool: + """Route commands to appropriate handler.""" + if command == "status": + from aipass.hooks.apps.modules.engine import find_project_config + + config = find_project_config() + if config is None: + sys.stdout.write("No .aipass/hooks.json found for current project\n") + else: + enabled = config.get("hooks_enabled", True) + sys.stdout.write(f"Hooks enabled: {enabled}\n") + for event_type, hooks in config.items(): + if event_type.startswith("_") or event_type == "hooks_enabled": + continue + if isinstance(hooks, dict): + active = sum(1 for h in hooks.values() if isinstance(h, dict) and h.get("enabled", True)) + total = sum(1 for h in hooks.values() if isinstance(h, dict)) + sys.stdout.write(f" {event_type}: {active}/{total} hooks active\n") + return True + + if command == "log": + log_file = Path(__file__).resolve().parent.parent / "logs" / "engine.jsonl" + if not log_file.exists(): + sys.stdout.write("No engine log found\n") + else: + lines = log_file.read_text().strip().split("\n") + for line in lines[-20:]: + sys.stdout.write(line + "\n") + return True + + return False + + +def main() -> int: + """Main entry point — routes commands or shows help.""" + args = sys.argv[1:] + + if not args: + data = print_introspection() + sys.stdout.write(f"HOOKS — {data['role']}\n") + sys.stdout.write(f" Modules: {', '.join(data['modules'])}\n") + sys.stdout.write(f" Bridges: {', '.join(data['bridges'])}\n") + sys.stdout.write(f" Commands: {', '.join(data['commands'])}\n") + return 0 + + if args[0] in ("--help", "-h", "help"): + print_help() + return 0 + + if args[0] in ("--version", "-V"): + sys.stdout.write("hooks 1.0.0\n") + return 0 + + command = args[0] + remaining = args[1:] if len(args) > 1 else [] + + if handle_command(command, remaining): + return 0 + + sys.stdout.write(f"Unknown command: {command}. Try: drone @hooks --help\n") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/aipass/hooks/apps/integrations/README.md b/src/aipass/hooks/apps/integrations/README.md new file mode 100644 index 00000000..c2dc6c5b --- /dev/null +++ b/src/aipass/hooks/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for `HOOKS`. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/{project}/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/hooks/apps/modules/README.md b/src/aipass/hooks/apps/modules/README.md new file mode 100644 index 00000000..cb464728 --- /dev/null +++ b/src/aipass/hooks/apps/modules/README.md @@ -0,0 +1,5 @@ +# Modules + +Business logic for `HOOKS`. One module per command. + +Modules orchestrate work by calling handlers. They are the public API of the branch — drone routes commands here. diff --git a/src/aipass/hooks/apps/modules/__init__.py b/src/aipass/hooks/apps/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/modules/engine.py b/src/aipass/hooks/apps/modules/engine.py new file mode 100644 index 00000000..39680887 --- /dev/null +++ b/src/aipass/hooks/apps/modules/engine.py @@ -0,0 +1,226 @@ +# =================== AIPass ==================== +# Name: engine.py +# Version: 1.0.0 +# Description: Hook engine — unified dispatcher for all hook events +# Branch: hooks +# Layer: apps/modules +# Created: 2026-05-18 +# Modified: 2026-05-18 +# ============================================= + +""" +Hook Engine — core dispatch logic. + +Reads per-project config (.aipass/hooks.json), routes to registered hooks, +logs everything via prax + JSONL. Called by platform bridges, not directly. +""" + +import json +import os +import subprocess +import time +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +AIPASS_HOME = os.environ.get("AIPASS_HOME", "") +BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent +LOG_FILE = BRANCH_ROOT / "logs" / "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("[HOOKS] 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: + LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except OSError as exc: + logger.error("[HOOKS] log write failed: %s", exc) + + +def _run_hook(hook_cmd: str, stdin_data: str, timeout_s: int = 30) -> dict: + """Run a single hook subprocess, capture output and timing.""" + env = os.environ.copy() + start = time.monotonic() + try: + result = subprocess.run( + hook_cmd, + shell=True, + input=stdin_data, + capture_output=True, + text=True, + timeout=timeout_s, + env=env, + ) + elapsed_ms = (time.monotonic() - start) * 1000 + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "elapsed_ms": round(elapsed_ms, 1), + } + except subprocess.TimeoutExpired: + elapsed_ms = (time.monotonic() - start) * 1000 + logger.error("[HOOKS] timeout after %ds: %s", timeout_s, hook_cmd) + return {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": round(elapsed_ms, 1)} + except OSError as exc: + elapsed_ms = (time.monotonic() - start) * 1000 + logger.error("[HOOKS] exec error: %s: %s", hook_cmd, exc) + return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)} + + +def _matches(matcher: str, value: str) -> bool: + """Check if a hook's matcher string matches the given value. Empty matcher = always match.""" + if not matcher: + return True + return value in matcher.split("|") + + +def dispatch(event_type: str, stdin_data: str, config: dict) -> str: + """Core dispatch — run hooks for event, return merged stdout. + + Bail semantics: first hook returning exit=2 with {"decision":"block"} JSON + on stdout stops execution. Exit=2 without JSON = crash (log + continue). + """ + if not config.get("hooks_enabled", True): + logger.info("[HOOKS] all hooks disabled") + _log({"ts": time.time(), "event": event_type, "action": "all_hooks_disabled"}) + return "" + + event_hooks = config.get(event_type, {}) + if not event_hooks: + _log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"}) + return "" + + match_value = "" + try: + parsed = json.loads(stdin_data) if stdin_data.strip() else {} + match_value = parsed.get("tool_name", "") or parsed.get("compact_type", "") or parsed.get("type", "") + except json.JSONDecodeError as exc: + logger.warning("[HOOKS] stdin parse error: %s", exc) + + outputs = [] + total_start = time.monotonic() + + for hook_name, hook_def in event_hooks.items(): + if not hook_def.get("enabled", True): + logger.info("[HOOKS] %s.%s skipped (disabled)", event_type, hook_name) + _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_disabled"}) + continue + + 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 + + hook_timeout = hook_def.get("timeout", 30) + result = _run_hook(command, stdin_data, timeout_s=hook_timeout) + + logger.info( + "[HOOKS] %s.%s exit=%d out=%db %dms", + event_type, + hook_name, + result["exit_code"], + len(result["stdout"]), + result["elapsed_ms"], + ) + _log( + { + "ts": time.time(), + "event": event_type, + "hook": hook_name, + "exit_code": result["exit_code"], + "elapsed_ms": result["elapsed_ms"], + "stdout_len": len(result["stdout"]), + "stderr_preview": result["stderr"][:200] if result["stderr"] else "", + "cwd": str(Path.cwd()), + } + ) + + # Exit code 2: crash vs intentional block + if result["exit_code"] == 2: + is_intentional_block = False + try: + decision = json.loads(result["stdout"]) if result["stdout"].strip() else {} + is_intentional_block = decision.get("decision") == "block" + except (json.JSONDecodeError, AttributeError): + logger.info("[HOOKS] %s.%s exit=2 stdout not JSON, treating as crash", event_type, hook_name) + + if is_intentional_block: + total_ms = (time.monotonic() - total_start) * 1000 + logger.warning("[HOOKS] %s BLOCKED by %s (%dms)", event_type, hook_name, total_ms) + _log( + { + "ts": time.time(), + "event": event_type, + "action": "blocked", + "hook": hook_name, + "total_ms": round(total_ms, 1), + } + ) + return result["stdout"] + + logger.error( + "[HOOKS] %s.%s CRASHED exit=2: %s", + event_type, + hook_name, + result["stderr"][:200], + ) + _log( + { + "ts": time.time(), + "event": event_type, + "hook": hook_name, + "action": "crashed", + "stderr": result["stderr"][:200], + } + ) + + if result["stdout"]: + outputs.append(result["stdout"]) + + total_ms = (time.monotonic() - total_start) * 1000 + logger.info("[HOOKS] %s complete: %d hooks %dms", event_type, len(outputs), total_ms) + _log( + { + "ts": time.time(), + "event": event_type, + "action": "complete", + "hooks_run": len(outputs), + "total_ms": round(total_ms, 1), + } + ) + + return "\n".join(outputs) diff --git a/src/aipass/hooks/apps/plugins/README.md b/src/aipass/hooks/apps/plugins/README.md new file mode 100644 index 00000000..cb1ed279 --- /dev/null +++ b/src/aipass/hooks/apps/plugins/README.md @@ -0,0 +1,5 @@ +# Plugins + +Scheduled tasks and extensions for `HOOKS`. + +Plugins are standalone units of work that can be scheduled via the daemon. Each plugin handles one specific recurring task. diff --git a/src/aipass/hooks/apps/plugins/__init__.py b/src/aipass/hooks/apps/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/docs/README.md b/src/aipass/hooks/docs/README.md new file mode 100644 index 00000000..e8e0c72a --- /dev/null +++ b/src/aipass/hooks/docs/README.md @@ -0,0 +1,3 @@ +# Docs + +Documentation files for the `HOOKS` branch. diff --git a/src/aipass/hooks/pytest.ini b/src/aipass/hooks/pytest.ini new file mode 100644 index 00000000..ae4e1b86 --- /dev/null +++ b/src/aipass/hooks/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +# Test discovery paths +testpaths = tests + +# Test file patterns +python_files = test_*.py +python_functions = test_* +python_classes = Test* + +# Command-line options (always applied) +addopts = -v --tb=short --strict-markers -ra + +# Test markers (for categorizing tests) +markers = + unit: Unit tests + integration: Integration tests + slow: Tests that take significant time diff --git a/src/aipass/hooks/requirements.project.txt b/src/aipass/hooks/requirements.project.txt new file mode 100644 index 00000000..bb3eb852 --- /dev/null +++ b/src/aipass/hooks/requirements.project.txt @@ -0,0 +1,3 @@ +# Project-specific Python packages only. +# These are installed into the AIPass venv: pip install -r requirements.project.txt +# Framework packages (drone, prax, chromadb, rich, etc.) are already available via AIPass. diff --git a/src/aipass/hooks/templates/README.md b/src/aipass/hooks/templates/README.md new file mode 100644 index 00000000..d2113202 --- /dev/null +++ b/src/aipass/hooks/templates/README.md @@ -0,0 +1,5 @@ +# Templates + +Branch-specific templates for `HOOKS`. + +Any templates this branch provides to the system or uses internally. Examples: plan templates (flow), trinity templates (memory), test templates (seedgo). diff --git a/src/aipass/hooks/tests/README.md b/src/aipass/hooks/tests/README.md new file mode 100644 index 00000000..29d5cfad --- /dev/null +++ b/src/aipass/hooks/tests/README.md @@ -0,0 +1,6 @@ +# Tests + +Pytest unit tests for `HOOKS`. + +- `conftest.py` — Shared fixtures (temp dirs, mocks, sample data). +- `test_*.py` — Test files. Standard tests cover JSON handler, CLI routing, and error resilience. Custom tests cover branch-specific domain logic. diff --git a/src/aipass/hooks/tests/__init__.py b/src/aipass/hooks/tests/__init__.py new file mode 100644 index 00000000..243b5d7b --- /dev/null +++ b/src/aipass/hooks/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for hooks diff --git a/src/aipass/hooks/tests/conftest.py b/src/aipass/hooks/tests/conftest.py new file mode 100644 index 00000000..3df7d0ef --- /dev/null +++ b/src/aipass/hooks/tests/conftest.py @@ -0,0 +1,80 @@ +# =================== AIPass ==================== +# Name: conftest.py +# Version: 1.0.0 +# Description: Shared pytest fixtures for hooks tests +# Branch: hooks +# Layer: tests +# Created: 2026-05-18 +# Modified: 2026-05-18 +# ============================================= + +"""Shared pytest fixtures for hooks tests.""" + +import json +import shutil +import tempfile +from pathlib import Path +from typing import Generator +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def temp_test_dir() -> Generator[Path, None, None]: + """Creates temporary directory for testing, cleans up after.""" + test_dir = Path(tempfile.mkdtemp()) + yield test_dir + if test_dir.exists(): + shutil.rmtree(test_dir) + + +@pytest.fixture +def sample_hooks_config() -> dict: + """Minimal hooks.json config for testing.""" + return { + "hooks_enabled": True, + "UserPromptSubmit": { + "test_hook": { + "enabled": True, + "command": "echo 'test output'", + "matcher": "", + } + }, + "PreToolUse": { + "matcher_hook": { + "enabled": True, + "command": "echo 'matched'", + "matcher": "Edit|Write", + }, + "disabled_hook": { + "enabled": False, + "command": "echo 'should not fire'", + "matcher": "", + }, + }, + } + + +@pytest.fixture +def hooks_config_file(temp_test_dir: Path, sample_hooks_config: dict) -> Path: + """Creates a .aipass/hooks.json in temp dir.""" + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + config_file = config_dir / "hooks.json" + config_file.write_text(json.dumps(sample_hooks_config), encoding="utf-8") + return config_file + + +@pytest.fixture +def mock_logger(): + """Mock the prax system logger.""" + with patch("aipass.hooks.apps.modules.engine.logger") as mock: + yield mock + + +@pytest.fixture +def mock_subprocess(): + """Mock subprocess.run for hook execution tests.""" + with patch("aipass.hooks.apps.modules.engine.subprocess.run") as mock: + yield mock diff --git a/src/aipass/hooks/tests/test_engine.py b/src/aipass/hooks/tests/test_engine.py new file mode 100644 index 00000000..56a15bcb --- /dev/null +++ b/src/aipass/hooks/tests/test_engine.py @@ -0,0 +1,730 @@ +# =================== AIPass ==================== +# Name: test_engine.py +# Version: 1.0.0 +# Description: Tests for hook engine dispatch logic +# Branch: hooks +# Layer: tests +# Created: 2026-05-18 +# Modified: 2026-05-18 +# ============================================= + +"""Tests for hook engine dispatch logic.""" + +import json +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + + +from aipass.hooks.apps.modules.engine import ( + dispatch, + find_project_config, + _matches, + _run_hook, + _log, +) + + +class TestMatches: + """Tests for _matches() matcher logic.""" + + def test_empty_matcher_always_matches(self): + assert _matches("", "Edit") is True + + def test_empty_matcher_matches_empty_value(self): + assert _matches("", "") is True + + def test_single_match(self): + assert _matches("Edit", "Edit") is True + + def test_single_no_match(self): + assert _matches("Edit", "Bash") is False + + def test_pipe_delimited_match_first(self): + assert _matches("Edit|Write|MultiEdit", "Edit") is True + + def test_pipe_delimited_match_middle(self): + assert _matches("Edit|Write|MultiEdit", "Write") is True + + def test_pipe_delimited_match_last(self): + assert _matches("Edit|Write|MultiEdit", "MultiEdit") is True + + def test_pipe_delimited_no_match(self): + assert _matches("Edit|Write|MultiEdit", "Bash") is False + + def test_partial_name_does_not_match(self): + assert _matches("Edit", "MultiEdit") is False + + def test_manual_auto_compact(self): + assert _matches("manual|auto", "manual") is True + assert _matches("manual|auto", "auto") is True + + +class TestRunHook: + """Tests for _run_hook() subprocess execution.""" + + def test_successful_hook(self, mock_subprocess, mock_logger): + mock_subprocess.return_value = MagicMock(returncode=0, stdout="hello", stderr="") + result = _run_hook("echo hello", "stdin_data") + assert result["exit_code"] == 0 + assert result["stdout"] == "hello" + assert result["elapsed_ms"] >= 0 + + def test_hook_with_nonzero_exit(self, mock_subprocess, mock_logger): + mock_subprocess.return_value = MagicMock(returncode=1, stdout="", stderr="error") + result = _run_hook("false", "") + assert result["exit_code"] == 1 + assert result["stderr"] == "error" + + def test_hook_timeout(self, mock_subprocess, mock_logger): + mock_subprocess.side_effect = subprocess.TimeoutExpired("cmd", 30) + result = _run_hook("sleep 999", "", timeout_s=30) + assert result["exit_code"] == -1 + assert result["stderr"] == "TIMEOUT" + + def test_hook_os_error(self, mock_subprocess, mock_logger): + mock_subprocess.side_effect = OSError("No such file") + result = _run_hook("/nonexistent", "") + assert result["exit_code"] == -1 + assert "No such file" in result["stderr"] + + def test_custom_timeout(self, mock_subprocess, mock_logger): + mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="") + _run_hook("cmd", "", timeout_s=120) + mock_subprocess.assert_called_once() + assert mock_subprocess.call_args.kwargs["timeout"] == 120 + + def test_default_timeout_is_30(self, mock_subprocess, mock_logger): + mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="") + _run_hook("cmd", "") + assert mock_subprocess.call_args.kwargs["timeout"] == 30 + + +class TestDispatch: + """Tests for dispatch() core logic.""" + + def test_hooks_disabled_returns_empty(self, mock_logger): + config = {"hooks_enabled": False} + with patch("aipass.hooks.apps.modules.engine._log"): + result = dispatch("UserPromptSubmit", "{}", config) + assert result == "" + + def test_no_hooks_for_event_returns_empty(self, mock_logger): + config = {"hooks_enabled": True} + with patch("aipass.hooks.apps.modules.engine._log"): + result = dispatch("UnknownEvent", "{}", config) + assert result == "" + + def test_disabled_hook_skipped(self, mock_logger): + config = { + "hooks_enabled": True, + "PreToolUse": { + "disabled_hook": { + "enabled": False, + "command": "echo fail", + "matcher": "", + } + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) + mock_run.assert_not_called() + assert result == "" + + def test_matcher_filters_hooks(self, mock_logger): + config = { + "hooks_enabled": True, + "PreToolUse": { + "edit_only": { + "enabled": True, + "command": "echo matched", + "matcher": "Edit|Write", + } + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 0, "stdout": "matched", "stderr": "", "elapsed_ms": 10} + dispatch("PreToolUse", '{"tool_name":"Bash"}', config) + mock_run.assert_not_called() + + def test_matching_hook_fires(self, mock_logger): + config = { + "hooks_enabled": True, + "PreToolUse": { + "edit_hook": { + "enabled": True, + "command": "echo edit_output", + "matcher": "Edit|Write", + } + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 0, "stdout": "edit_output", "stderr": "", "elapsed_ms": 10} + result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) + mock_run.assert_called_once() + assert "edit_output" in result + + def test_multiple_hooks_concatenate_output(self, mock_logger): + config = { + "hooks_enabled": True, + "UserPromptSubmit": { + "hook_a": {"enabled": True, "command": "echo A", "matcher": ""}, + "hook_b": {"enabled": True, "command": "echo B", "matcher": ""}, + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.side_effect = [ + {"exit_code": 0, "stdout": "output_A", "stderr": "", "elapsed_ms": 10}, + {"exit_code": 0, "stdout": "output_B", "stderr": "", "elapsed_ms": 10}, + ] + result = dispatch("UserPromptSubmit", '{"user_prompt":"test"}', config) + assert "output_A" in result + assert "output_B" in result + + def test_exit2_with_block_json_bails(self, mock_logger): + config = { + "hooks_enabled": True, + "PreToolUse": { + "blocker": {"enabled": True, "command": "block_cmd", "matcher": ""}, + "after_block": {"enabled": True, "command": "should_not_run", "matcher": ""}, + }, + } + block_json = json.dumps({"decision": "block", "reason": "test block"}) + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 2, "stdout": block_json, "stderr": "", "elapsed_ms": 10} + result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) + assert mock_run.call_count == 1 + parsed = json.loads(result) + assert parsed["decision"] == "block" + + def test_exit2_without_json_is_crash_not_block(self, mock_logger): + config = { + "hooks_enabled": True, + "PreToolUse": { + "crashed": {"enabled": True, "command": "crash_cmd", "matcher": ""}, + "next_hook": {"enabled": True, "command": "echo survived", "matcher": ""}, + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.side_effect = [ + {"exit_code": 2, "stdout": "", "stderr": "file not found", "elapsed_ms": 10}, + {"exit_code": 0, "stdout": "survived", "stderr": "", "elapsed_ms": 10}, + ] + result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) + assert mock_run.call_count == 2 + assert "survived" in result + + def test_hook_with_custom_timeout(self, mock_logger): + config = { + "hooks_enabled": True, + "PreCompact": { + "slow_hook": { + "enabled": True, + "command": "slow_cmd", + "matcher": "", + "timeout": 120, + } + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 0, "stdout": "", "stderr": "", "elapsed_ms": 50} + dispatch("PreCompact", '{"type":"manual"}', config) + mock_run.assert_called_once_with("slow_cmd", '{"type":"manual"}', timeout_s=120) + + def test_empty_command_skipped(self, mock_logger): + config = { + "hooks_enabled": True, + "Stop": { + "no_command": {"enabled": True, "command": "", "matcher": ""}, + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + dispatch("Stop", "{}", config) + mock_run.assert_not_called() + + def test_malformed_stdin_does_not_crash(self, mock_logger): + config = { + "hooks_enabled": True, + "UserPromptSubmit": { + "hook": {"enabled": True, "command": "echo ok", "matcher": ""}, + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 0, "stdout": "ok", "stderr": "", "elapsed_ms": 5} + result = dispatch("UserPromptSubmit", "not json at all{{{", config) + assert "ok" in result + + +class TestFindProjectConfig: + """Tests for find_project_config() CWD walk.""" + + def test_finds_config_in_cwd(self, hooks_config_file, temp_test_dir, mock_logger): + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + config = find_project_config() + assert config is not None + assert config["hooks_enabled"] is True + + def test_returns_none_when_no_config(self, temp_test_dir, mock_logger): + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + with patch("aipass.hooks.apps.modules.engine.Path.home", return_value=temp_test_dir.parent): + config = find_project_config() + assert config is None + + def test_expands_aipass_home(self, temp_test_dir, mock_logger): + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + config = { + "hooks_enabled": True, + "Stop": {"sound": {"enabled": True, "command": "python3 $AIPASS_HOME/hook.py", "matcher": ""}}, + } + (config_dir / "hooks.json").write_text(json.dumps(config)) + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + with patch("aipass.hooks.apps.modules.engine.AIPASS_HOME", "/test/path"): + result = find_project_config() + assert "/test/path/hook.py" in result["Stop"]["sound"]["command"] + + +class TestLog: + """Tests for _log() JSONL diagnostics.""" + + def test_writes_jsonl_entry(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "test.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"event": "Test", "action": "test_write"}) + lines = log_file.read_text().strip().split("\n") + assert len(lines) == 1 + entry = json.loads(lines[0]) + assert entry["event"] == "Test" + assert entry["action"] == "test_write" + + def test_creates_parent_directory(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "subdir" / "test.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"event": "Test"}) + assert log_file.exists() + + def test_appends_multiple_entries(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "test.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"event": "A"}) + _log({"event": "B"}) + lines = log_file.read_text().strip().split("\n") + assert len(lines) == 2 + + +class TestHooksEntryPoint: + """Tests for hooks.py CLI routing.""" + + def test_help_returns_zero(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "--help"]): + assert main() == 0 + + def test_version_returns_zero(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "--version"]): + assert main() == 0 + + def test_no_args_shows_help(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks"]): + assert main() == 0 + + def test_unknown_command_returns_one(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "nonexistent_command"]): + assert main() == 1 + + def test_print_introspection_returns_dict(self): + from aipass.hooks.apps.hooks import print_introspection + + result = print_introspection() + assert isinstance(result, dict) + assert result["branch"] == "hooks" + assert "engine" in result["modules"] + + def test_handle_command_returns_bool(self): + from aipass.hooks.apps.hooks import handle_command + + result = handle_command("nonexistent", []) + assert result is False + + def test_status_command_returns_true(self): + from aipass.hooks.apps.hooks import handle_command + + with patch("aipass.hooks.apps.modules.engine.find_project_config", return_value=None): + result = handle_command("status", []) + assert result is True + + def test_log_command_returns_true(self): + from aipass.hooks.apps.hooks import handle_command + + result = handle_command("log", []) + assert result is True + + def test_short_help_flag(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "-h"]): + assert main() == 0 + + def test_help_word(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "help"]): + assert main() == 0 + + +class TestErrorResilience: + """Tests for error handling edge cases.""" + + def test_missing_hooks_json_file(self, temp_test_dir, mock_logger): + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + with patch("aipass.hooks.apps.modules.engine.Path.home", return_value=temp_test_dir.parent): + config = find_project_config() + assert config is None + + def test_corrupt_hooks_json(self, temp_test_dir, mock_logger): + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + (config_dir / "hooks.json").write_text("{invalid json!!!") + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + config = find_project_config() + assert config is None + + def test_empty_hooks_json(self, temp_test_dir, mock_logger): + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + (config_dir / "hooks.json").write_text("") + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + config = find_project_config() + assert config is None + + def test_log_write_failure_does_not_crash(self, mock_logger): + with patch("builtins.open", side_effect=OSError("disk full")): + with patch("aipass.hooks.apps.modules.engine.LOG_FILE") as mock_path: + mock_path.parent.mkdir = MagicMock() + _log({"event": "Test"}) + + +class TestDataStructureContracts: + """Tests for config and log data structures.""" + + def test_config_has_hooks_enabled_key(self, sample_hooks_config): + assert "hooks_enabled" in sample_hooks_config + + def test_config_event_values_are_dicts(self, sample_hooks_config): + for key, val in sample_hooks_config.items(): + if key == "hooks_enabled": + continue + assert isinstance(val, dict) + + def test_log_entry_has_required_fields(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "test.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"ts": 123.0, "event": "Test", "action": "check"}) + entry = json.loads(log_file.read_text().strip()) + assert "ts" in entry + assert "event" in entry + + +class TestExceptionContracts: + """Tests for expected exceptions and error paths.""" + + def test_dispatch_with_none_config_event_returns_empty(self, mock_logger): + with patch("aipass.hooks.apps.modules.engine._log"): + result = dispatch("Stop", "{}", {"hooks_enabled": True}) + assert result == "" + + def test_run_hook_timeout_returns_negative_exit(self, mock_subprocess, mock_logger): + mock_subprocess.side_effect = subprocess.TimeoutExpired("cmd", 30) + result = _run_hook("cmd", "") + assert result["exit_code"] == -1 + + def test_run_hook_os_error_returns_negative_exit(self, mock_subprocess, mock_logger): + mock_subprocess.side_effect = OSError("not found") + result = _run_hook("cmd", "") + assert result["exit_code"] == -1 + + +class TestInfrastructureMocking: + """Tests verifying mock infrastructure works correctly.""" + + def test_mock_logger_fixture(self, mock_logger): + assert mock_logger is not None + + def test_mock_subprocess_fixture(self, mock_subprocess): + assert mock_subprocess is not None + mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="") + _run_hook("test", "") + mock_subprocess.assert_called_once() + + def test_hooks_config_file_fixture(self, hooks_config_file): + assert hooks_config_file.exists() + config = json.loads(hooks_config_file.read_text()) + assert config["hooks_enabled"] is True + + +class TestInitProvisioning: + """Tests for initialization and directory setup.""" + + def test_log_auto_creates_directory(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "new_dir" / "engine.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"event": "init_test"}) + assert log_file.parent.exists() + + def test_config_walks_up_from_subdirectory(self, temp_test_dir, mock_logger): + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + (config_dir / "hooks.json").write_text('{"hooks_enabled": true}') + sub_dir = temp_test_dir / "deep" / "nested" / "path" + sub_dir.mkdir(parents=True) + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=sub_dir): + config = find_project_config() + assert config is not None + assert config["hooks_enabled"] is True + + def test_log_no_overwrite_on_append(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "test.jsonl" + log_file.write_text('{"existing": true}\n') + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"event": "new"}) + lines = log_file.read_text().strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0])["existing"] is True + + def test_dispatch_returns_string(self, mock_logger): + with patch("aipass.hooks.apps.modules.engine._log"): + result = dispatch("Stop", "{}", {"hooks_enabled": True}) + assert isinstance(result, str) + + +class TestConftest: + """Tests verifying conftest fixtures and mock infrastructure.""" + + def test_sample_hooks_config_has_events(self, sample_hooks_config): + assert "UserPromptSubmit" in sample_hooks_config + assert "PreToolUse" in sample_hooks_config + + def test_sample_data_structure(self, sample_hooks_config): + hook = sample_hooks_config["UserPromptSubmit"]["test_hook"] + assert "enabled" in hook + assert "command" in hook + assert "matcher" in hook + + def test_hooks_config_file_is_valid_json(self, hooks_config_file): + content = json.loads(hooks_config_file.read_text()) + assert isinstance(content, dict) + + def test_temp_test_dir_exists(self, temp_test_dir): + assert temp_test_dir.exists() + assert temp_test_dir.is_dir() + + def test_mock_logger_is_mock(self, mock_logger): + mock_logger.info("test") + mock_logger.info.assert_called_once() + + def test_autouse_mock_subprocess(self, mock_subprocess): + mock_subprocess.return_value = MagicMock(returncode=0, stdout="x", stderr="") + result = _run_hook("test_cmd", "input") + assert result["stdout"] == "x" + + def test_sys_modules_mock_logger(self, mock_logger): + mock_logger.error("error msg") + mock_logger.error.assert_called_with("error msg") + + def test_reimport_after_mock(self, mock_logger): + from aipass.hooks.apps.modules import engine + + assert hasattr(engine, "dispatch") + assert hasattr(engine, "find_project_config") + + +class TestCliRouting: + """Additional CLI routing tests for print_help and output capture.""" + + def test_print_help_writes_to_stdout(self, capsys): + from aipass.hooks.apps.hooks import print_help + + print_help() + captured = capsys.readouterr() + assert "HOOKS" in captured.out + assert "drone @hooks" in captured.out + + def test_output_capture_status(self, capsys): + from aipass.hooks.apps.hooks import handle_command + + with patch("aipass.hooks.apps.modules.engine.find_project_config", return_value=None): + handle_command("status", []) + captured = capsys.readouterr() + assert "No .aipass/hooks.json" in captured.out + + def test_version_output(self, capsys): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "--version"]): + main() + captured = capsys.readouterr() + assert "1.0.0" in captured.out + + +class TestConfigDataContracts: + """Additional data structure contract tests.""" + + def test_config_keys_are_strings(self, sample_hooks_config): + for key in sample_hooks_config: + assert isinstance(key, str) + + def test_hook_def_has_command_key(self, sample_hooks_config): + hook = sample_hooks_config["UserPromptSubmit"]["test_hook"] + assert "command" in hook + assert isinstance(hook["command"], str) + + def test_data_keys_in_log_entry(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "test.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"ts": 1.0, "event": "Test", "hook": "test_hook", "exit_code": 0}) + entry = json.loads(log_file.read_text().strip()) + assert "ts" in entry + assert "event" in entry + assert "hook" in entry + assert "exit_code" in entry + + +class TestPathContracts: + """Tests for path-returning functions.""" + + def test_paths_return_path(self): + from aipass.hooks.apps.modules.engine import BRANCH_ROOT, LOG_FILE + + assert isinstance(BRANCH_ROOT, Path) + assert isinstance(LOG_FILE, Path) + + +class TestErrorResilienceExtended: + """Additional error resilience tests.""" + + def test_dispatch_with_empty_stdin(self, mock_logger): + config = { + "hooks_enabled": True, + "Stop": {"hook": {"enabled": True, "command": "echo ok", "matcher": ""}}, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 0, "stdout": "ok", "stderr": "", "elapsed_ms": 5} + result = dispatch("Stop", "", config) + assert "ok" in result + + def test_config_with_nonexistent_dir(self, temp_test_dir, mock_logger): + nonexistent = temp_test_dir / "does_not_exist" + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=nonexistent): + with patch("aipass.hooks.apps.modules.engine.Path.home", return_value=temp_test_dir): + config = find_project_config() + assert config is None + + def test_missing_file_in_log_path(self, temp_test_dir, mock_logger): + missing = temp_test_dir / "missing" / "nonexistent.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", missing): + _log({"event": "test_missing"}) + assert missing.exists() + + def test_empty_file_config(self, temp_test_dir, mock_logger): + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + empty_file = config_dir / "hooks.json" + empty_file.write_text("") + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + config = find_project_config() + assert config is None + + +class TestMockInfrastructure: + """Tests verifying sys.modules mocking and reimport patterns.""" + + def test_sys_modules_mock(self): + import sys + + assert "aipass.hooks.apps.modules.engine" in sys.modules + + def test_reimport_after_mock(self, mock_logger): + import importlib + from aipass.hooks.apps.modules import engine + + importlib.reload(engine) + assert hasattr(engine, "dispatch") + assert hasattr(engine, "_matches") + assert hasattr(engine, "_run_hook") + + +class TestJsonHandlerNotApplicable: + """Hooks uses JSONL logging, not json_handler. These verify the log equivalent.""" + + def test_log_default_factory(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "factory.jsonl" + assert not log_file.exists() + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"event": "factory_test"}) + assert log_file.exists() + + def test_log_validate_json_output(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "validate.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"event": "A", "ts": 1.0}) + _log({"event": "B", "ts": 2.0}) + for line in log_file.read_text().strip().split("\n"): + entry = json.loads(line) + assert isinstance(entry, dict) + + def test_log_get_path(self): + from aipass.hooks.apps.modules.engine import LOG_FILE + + assert LOG_FILE.name == "engine.jsonl" + assert "logs" in str(LOG_FILE) + + def test_log_ensure_exists(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "new_dir" / "ensure.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"ensure": True}) + assert log_file.parent.exists() + + def test_log_save_entry(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "save.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"saved": True, "value": 42}) + entry = json.loads(log_file.read_text().strip()) + assert entry["saved"] is True + assert entry["value"] == 42 + + def test_log_load_entry(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "load.jsonl" + log_file.write_text('{"loaded": true}\n') + lines = log_file.read_text().strip().split("\n") + entry = json.loads(lines[0]) + assert entry["loaded"] is True + + def test_log_operation_recorded(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "ops.jsonl" + with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + _log({"event": "PreToolUse", "hook": "test", "exit_code": 0}) + entry = json.loads(log_file.read_text().strip()) + assert entry["event"] == "PreToolUse" + + def test_log_ensure_module(self): + from aipass.hooks.apps.modules import engine + + assert hasattr(engine, "_log") + assert callable(engine._log) diff --git a/src/aipass/memory/apps/handlers/monitor/detector.py b/src/aipass/memory/apps/handlers/monitor/detector.py index b37be69c..0d6af026 100644 --- a/src/aipass/memory/apps/handlers/monitor/detector.py +++ b/src/aipass/memory/apps/handlers/monitor/detector.py @@ -286,11 +286,11 @@ def _should_rollover(file_path: Path) -> tuple[bool, int, int, str, str]: return (current_lines >= 600, current_lines, 600, "1.0.0", "") metadata = data.get("document_metadata", {}) - schema_version = metadata.get("schema_version", "1.0.0") limits = metadata.get("limits", {}) - # v2: entry-count based limits - if schema_version.startswith("2"): + # v2: entry-count based limits (checked when v2 limit keys are present, regardless of schema_version) + v2_limit_keys = {"max_sessions", "max_key_learnings", "max_observations"} + if v2_limit_keys & set(limits.keys()): reasons = [] max_sessions = limits.get("max_sessions") @@ -311,10 +311,10 @@ def _should_rollover(file_path: Path) -> tuple[bool, int, int, str, str]: if isinstance(observations, list) and len(observations) >= max_observations: reasons.append(f"{len(observations)}/{max_observations} observations") - triggered = len(reasons) > 0 - return (triggered, current_lines, 0, schema_version, ", ".join(reasons)) + if reasons: + return (True, current_lines, 0, "2.0.0", ", ".join(reasons)) - # v1: line-count based + # v1: line-count based (fallback when no v2 limits triggered) max_lines = limits.get("max_lines") if max_lines is None: max_lines = _get_max_lines(file_path) diff --git a/src/aipass/memory/apps/handlers/rollover/extractor.py b/src/aipass/memory/apps/handlers/rollover/extractor.py index a0862b0c..eb17565e 100644 --- a/src/aipass/memory/apps/handlers/rollover/extractor.py +++ b/src/aipass/memory/apps/handlers/rollover/extractor.py @@ -372,9 +372,10 @@ def extract_items(file_path: Path, percentage: int | None = None) -> Dict[str, A logger.warning(f"[extractor] Failed to read file {file_path}: {e}") return {"success": False, "error": f"Failed to read file: {e}"} - # v2 schema: delegate to entry-count based extraction - schema_version = data.get("document_metadata", {}).get("schema_version", "1.0.0") - if schema_version.startswith("2"): + # v2: entry-count based extraction (when v2 limit keys are present, regardless of schema_version) + ext_limits = data.get("document_metadata", {}).get("limits", {}) + v2_limit_keys = {"max_sessions", "max_key_learnings", "max_observations"} + if v2_limit_keys & set(ext_limits.keys()): return _extract_items_v2(file_path, data) # v1: line-count based extraction diff --git a/src/aipass/prax/apps/handlers/dashboard/refresh.py b/src/aipass/prax/apps/handlers/dashboard/refresh.py index 565d7233..587b5afe 100644 --- a/src/aipass/prax/apps/handlers/dashboard/refresh.py +++ b/src/aipass/prax/apps/handlers/dashboard/refresh.py @@ -202,6 +202,16 @@ def _preserve_write_through_sections(dashboard: Dict, branch_path: Path, branch_ logger.warning("Failed to preserve write-through sections for %s: %s", branch_name, e) +def _run_devpulse_plugin(branch_path: Path) -> None: + """Invoke devpulse dashboard plugin refresh for custom sections.""" + try: + from aipass.prax.apps.plugins.devpulse_dashboard.refresh import refresh as devpulse_refresh + + devpulse_refresh(branch_path) + except Exception as e: + logger.warning("Devpulse plugin refresh failed: %s", e) + + def refresh_all_dashboards() -> Dict: """ Refresh all branch dashboards from central files. @@ -246,6 +256,11 @@ def refresh_all_dashboards() -> Dict: # Save save_dashboard(branch_path, dashboard) + + # Invoke devpulse plugin refresh for custom sections (git, session, dispatch) + if branch_name == "DEVPULSE": + _run_devpulse_plugin(branch_path) + branches_updated += 1 except Exception as e: @@ -304,6 +319,10 @@ def refresh_single_dashboard(branch_path: Path) -> Dict: save_dashboard(branch_path, dashboard) + # Invoke devpulse plugin refresh for custom sections (git, session, dispatch) + if branch_name == "DEVPULSE": + _run_devpulse_plugin(branch_path) + return {"status": "success", "branch": branch_name} except Exception as e: diff --git a/src/aipass/prax/apps/handlers/dashboard/template_pusher.py b/src/aipass/prax/apps/handlers/dashboard/template_pusher.py index bd5a3c45..2bed58ef 100644 --- a/src/aipass/prax/apps/handlers/dashboard/template_pusher.py +++ b/src/aipass/prax/apps/handlers/dashboard/template_pusher.py @@ -180,6 +180,35 @@ def _safe_write_dashboard( return False +def _update_spawn_template( + spawn_path: Path, template: dict, dry_run: bool, result: Dict[str, Any], updated_list: List[str] +) -> None: + """Apply structural updates to spawn's builder template, preserving placeholders.""" + if not spawn_path.exists(): + return + try: + spawn_data = json.loads(spawn_path.read_text()) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to read spawn template: %s", e) + result["errors"].append(f"SPAWN_TEMPLATE: {e}") + return + + spawn_actions: List[str] = [] + changed, spawn_actions = _apply_structural_updates(spawn_data, template, spawn_actions) + if not changed: + return + + spawn_data["branch"] = "{{BRANCHNAME}}" + spawn_data["last_updated"] = "{{DATE}}" + for section in spawn_data.get("sections", {}).values(): + if isinstance(section, dict) and "last_updated" in section: + section["last_updated"] = "{{DATE}}" + + if _safe_write_dashboard(spawn_path, spawn_data, "SPAWN_TEMPLATE", dry_run, result): + updated_list.append("SPAWN_TEMPLATE") + result["changes"].append({"branch": "SPAWN_TEMPLATE", "actions": spawn_actions}) + + def _apply_structural_updates(data: dict, template: dict, branch_actions: List[str]) -> tuple: """Apply structural updates from template to existing dashboard data. @@ -336,6 +365,10 @@ def push_dashboard_template(dry_run: bool = False) -> Dict[str, Any]: branches_updated_list.append(branch_name) result["changes"].append({"branch": branch_name, "actions": branch_actions}) + # Update spawn template (scaffold for new branches) + spawn_template_path = repo_root / "src" / "aipass" / "spawn" / "templates" / "builder" / "DASHBOARD.local.json" + _update_spawn_template(spawn_template_path, template, dry_run, result, branches_updated_list) + # Update version file if any branches were modified if not dry_run and branches_updated_list: _update_version_file(branches_updated_list) diff --git a/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py b/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py index 1722d809..567bd3d4 100644 --- a/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py +++ b/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py @@ -14,7 +14,7 @@ Failures in one section don't block others. """ from pathlib import Path -from typing import Dict, List +from typing import Dict, List, Optional from aipass.prax.apps.modules.logger import system_logger as logger @@ -24,7 +24,7 @@ _AIPASS_SRC = Path(__file__).resolve().parents[4] # .../src/aipass/ DEVPULSE_PATH = _AIPASS_SRC / "devpulse" -def refresh(branch_path: Path = None) -> Dict: +def refresh(branch_path: Optional[Path] = None) -> Dict: """Refresh all devpulse custom dashboard sections. Args: diff --git a/src/aipass/prax/templates/.dashboard_version.json b/src/aipass/prax/templates/.dashboard_version.json index 40236e44..75a57659 100644 --- a/src/aipass/prax/templates/.dashboard_version.json +++ b/src/aipass/prax/templates/.dashboard_version.json @@ -10,19 +10,9 @@ "description": "Branch dashboard template - v3 schema with write-through sections" } }, - "last_push": "2026-05-16 21:34:52", + "last_push": "2026-05-17 00:08:31", "last_push_branches": [ - "AI_MAIL", - "AIPASS", - "API", - "CLI", "DEVPULSE", - "DRONE", - "FLOW", - "MEMORY", - "PRAX", - "SEEDGO", - "SPAWN", - "TRIGGER" + "SPAWN_TEMPLATE" ] } diff --git a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json index 2647965d..236e7aa8 100644 --- a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json @@ -1,7 +1,7 @@ { "metadata": { "version": "1.0.0", - "last_updated": "2026-05-16", + "last_updated": "2026-05-18", "description": "Template file tracking registry for ID-based updates" }, "files": { @@ -104,7 +104,7 @@ "f016": { "path": "DASHBOARD.local.json", "name": "DASHBOARD.local.json", - "content_hash": "88360d943d4b", + "content_hash": "f4775daf1f75", "has_branch_placeholder": false }, "f017": { @@ -155,7 +155,7 @@ "content_hash": "a4cf0a8e3b4f", "has_branch_placeholder": false }, - "f015": { + "f026": { "path": "apps/modules/__init__.py", "name": "__init__.py", "content_hash": "e3b0c44298fc", @@ -269,7 +269,7 @@ "content_hash": "28e9ae373563", "has_branch_placeholder": false }, - "f026": { + "f015": { "path": "apps/plugins/__init__.py", "name": "__init__.py", "content_hash": "e3b0c44298fc", diff --git a/src/aipass/spawn/templates/builder/DASHBOARD.local.json b/src/aipass/spawn/templates/builder/DASHBOARD.local.json index 5360cd69..3c524cfc 100644 --- a/src/aipass/spawn/templates/builder/DASHBOARD.local.json +++ b/src/aipass/spawn/templates/builder/DASHBOARD.local.json @@ -6,9 +6,8 @@ "new_mail": 0, "opened_mail": 0, "active_plans": 0, - "commons_mentions": 0, "action_required": false, - "summary": "" + "summary": "All clear" }, "sections": { "ai_mail": { @@ -26,37 +25,11 @@ "recently_closed": [], "last_updated": "{{DATE}}" }, - "memory_bank": { - "managed_by": "memory_bank", + "memory": { + "managed_by": "memory", "vectors_stored": 0, "notes": {}, "last_updated": "{{DATE}}" - }, - "devpulse": { - "managed_by": "devpulse", - "summary": { - "issues": 0, - "todos": 0 - }, - "dplan_counts": {}, - "recent_activity": "", - "last_updated": "{{DATE}}" - }, - "commons_activity": { - "managed_by": "the_commons", - "new_posts_since_last_visit": 0, - "new_comments_since_last_visit": 0, - "mentions": 0, - "trending": "None", - "last_checked": null, - "last_updated": "{{DATE}}" - }, - "agent_status": { - "managed_by": "prax", - "active_agents": [], - "agent_count": 0, - "stale_agents": [], - "last_updated": "{{DATE}}" } } }