diff --git a/.aipass/.gitignore b/.aipass/.gitignore index bce632cb..9999acfb 100644 --- a/.aipass/.gitignore +++ b/.aipass/.gitignore @@ -4,4 +4,5 @@ !.gitignore !project_CLAUDE.md !project_global_prompt.md -#Do not add other exceptions here without careful consideration. Developer permissions0ns needed. \ No newline at end of file +!project_hooks.json +#Do not add other exceptions here without careful consideration. Developer permissions0ns needed. \ No newline at end of file diff --git a/.aipass/project_hooks.json b/.aipass/project_hooks.json new file mode 100644 index 00000000..4059135c --- /dev/null +++ b/.aipass/project_hooks.json @@ -0,0 +1,99 @@ +{ + "_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable ` or edit here.", + "hooks_enabled": true, + + "UserPromptSubmit": { + "identity_injector": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.prompt.identity.handle", + "matcher": "" + }, + "email_notification": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.notification.email.handle", + "matcher": "" + }, + "branch_prompt": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.prompt.branch_loader.handle", + "matcher": "" + }, + "global_prompt": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle", + "matcher": "" + } + }, + + "PreToolUse": { + "tool_use_sound": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.notification.tool_sound.handle", + "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task" + }, + "pre_edit_gate": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.security.edit_gate.handle", + "matcher": "Edit|MultiEdit|Write|NotebookEdit" + }, + "git_gate": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.security.git_gate.handle", + "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit" + } + }, + + "PostToolUse": { + "auto_fix_diagnostics": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.lifecycle.auto_fix.handle", + "matcher": "Edit|MultiEdit|Write|NotebookEdit", + "timeout": 45 + }, + "auto_watchdog": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle", + "matcher": "Bash" + } + }, + + "SubagentStop": { + "subagent_stop_gate": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.security.subagent_gate.handle", + "matcher": "", + "timeout": 60 + } + }, + + "Stop": { + "stop_sound": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.notification.stop_sound.handle", + "matcher": "" + } + }, + + "Notification": { + "notification_sound": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.notification.announce.handle", + "matcher": "" + } + }, + + "PreCompact": { + "pre_compact": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.lifecycle.compact.handle", + "matcher": "", + "timeout": 60 + }, + "pre_compact_rollover": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.lifecycle.rollover.handle", + "matcher": "", + "timeout": 120 + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd4d7e4..222ae72f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format ### Added +- **Hooks activate in every project** — `aipass init` now writes + `.aipass/hooks.json`, so new projects fire the hook engine out of the box + (previously: no config shipped, 0 hooks fired). `aipass init update` + union-merges the template, preserving any per-hook on/off choices the user + made. `aipass doctor` now checks for the config's presence. Dead hook-script + shipping (`_ship_hooks`) removed. (DPLAN-0190 Phase A) - **README logo** — centered logo image replaces plain `# AIPass` header. New `assets/logo.png` added to the repo. - **Registry descriptions** — all 13 branches now have one-liner descriptions diff --git a/src/aipass/aipass/README.md b/src/aipass/aipass/README.md index 00b8dec9..0b179275 100644 --- a/src/aipass/aipass/README.md +++ b/src/aipass/aipass/README.md @@ -76,4 +76,4 @@ Humans only. Nothing in AIPass depends on this branch. ## Last Updated -Last Updated: 2026-05-16 +Last Updated: 2026-05-28 diff --git a/src/aipass/aipass/apps/handlers/init/bootstrap.py b/src/aipass/aipass/apps/handlers/init/bootstrap.py index 386d779b..948fbfa1 100644 --- a/src/aipass/aipass/apps/handlers/init/bootstrap.py +++ b/src/aipass/aipass/apps/handlers/init/bootstrap.py @@ -43,60 +43,6 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc logger = logging.getLogger(__name__) -# 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", - "auto_fix_diagnostics.py", - "pre_edit_gate.py", - "subagent_stop_gate.py", -] - - -def _ship_hooks(aipass_home: str, target: Path) -> list[str]: - """Copy enforcement + injector hooks from AIPass install to target project. - - Copies each hook file to {target}/.claude/hooks/. Looks for hooks in two - locations (first match wins): - 1. {aipass_home}/.claude/hooks/ — dev install (git clone) - 2. aipass/_hooks/ — pip install (wheel-bundled) - - Skips audio hooks. Overwrites existing files only if source content - differs (idempotent re-sync). Returns list of files written. - """ - source_dir = Path(aipass_home) / ".claude" / "hooks" - if not source_dir.is_dir(): - # Fallback: pip install bundles hooks at aipass/_hooks/ inside the package - package_hooks = Path(__file__).resolve().parents[4] / "_hooks" - if package_hooks.is_dir(): - source_dir = package_hooks - else: - logger.info("No hooks directory at %s or %s — skipping", source_dir, package_hooks) - return [] - - dest_dir = target / ".claude" / "hooks" - dest_dir.mkdir(parents=True, exist_ok=True) - shipped: list[str] = [] - - for hook_name in HOOKS_TO_SHIP: - src = source_dir / hook_name - dst = dest_dir / hook_name - if not src.exists(): - logger.info("Hook %s not found at %s — skipping", hook_name, src) - continue - src_content = src.read_bytes() - if dst.exists() and dst.read_bytes() == src_content: - continue - shutil.copy2(src, dst) - shipped.append(str(dst)) - - return shipped - def _sanitize_name(raw: str) -> str: """Sanitize a project name for use in filenames. @@ -201,6 +147,52 @@ def _merge_settings(existing: dict, generated: dict) -> dict: return merged +def _merge_hooks_json(existing: dict, template: dict) -> dict: + """Union-merge hooks.json: preserve user enabled values, add new hooks/events.""" + merged: dict = {} + meta_keys = {"_comment", "hooks_enabled"} + + if "_comment" in template: + merged["_comment"] = template["_comment"] + elif "_comment" in existing: + merged["_comment"] = existing["_comment"] + + if "hooks_enabled" in existing: + merged["hooks_enabled"] = existing["hooks_enabled"] + elif "hooks_enabled" in template: + merged["hooks_enabled"] = template["hooks_enabled"] + + all_events: set[str] = set() + for key in existing: + if key not in meta_keys: + all_events.add(key) + for key in template: + if key not in meta_keys: + all_events.add(key) + + for event in sorted(all_events): + existing_hooks = existing.get(event, {}) + template_hooks = template.get(event, {}) + merged_hooks: dict = {} + + for hook_name, hook_data in existing_hooks.items(): + merged_hooks[hook_name] = dict(hook_data) + + for hook_name, hook_data in template_hooks.items(): + if hook_name in merged_hooks: + user_enabled = merged_hooks[hook_name].get("enabled") + merged_hooks[hook_name] = dict(hook_data) + if user_enabled is not None: + merged_hooks[hook_name]["enabled"] = user_enabled + else: + merged_hooks[hook_name] = dict(hook_data) + + if merged_hooks: + merged[event] = merged_hooks + + return merged + + def _claude_settings(aipass_home: str | None = None) -> str: """Generate .claude/settings.json — env and permissions only. @@ -338,6 +330,16 @@ def init_project(target: Path, project_name: str | None = None) -> dict: global_prompt_path.write_text(_resolve_global_prompt(name, aipass_home, global_prompt_path), encoding="utf-8") created.append(str(global_prompt_path)) + # 2b. .aipass/hooks.json — project hook config from template + hooks_json_path = aipass_dir / "hooks.json" + if not hooks_json_path.exists() and aipass_home: + template = Path(aipass_home) / ".aipass" / "project_hooks.json" + if template.is_file(): + shutil.copy2(str(template), str(hooks_json_path)) + created.append(str(hooks_json_path)) + else: + logger.info("hooks template not found at %s — skipping", template) + # 3-5. CLAUDE.md, AGENTS.md — project templates or AIPass source for md_name in ("CLAUDE.md", "AGENTS.md"): dest = target / md_name @@ -396,11 +398,6 @@ def init_project(target: Path, project_name: str | None = None) -> dict: prep_path.write_text(sc.prep_md(), encoding="utf-8") created.append(str(prep_path)) - # 9d. Ship enforcement + injector hooks from AIPass install - if aipass_home: - shipped = _ship_hooks(aipass_home, target) - created.extend(shipped) - # 10. src// package structure (pip-installable from day one) package_name = raw_name.lower().replace("-", "_").replace(" ", "_") src_dir = target / "src" @@ -532,6 +529,34 @@ def update_project(target: Path) -> dict: else: already_current.append(str(settings_path)) + # hooks.json — union-merge: preserve user enabled, add new hooks from template + hooks_json_path = aipass_dir / "hooks.json" + hook_home = aipass_home or _detect_aipass_home() + template_path = Path(hook_home) / ".aipass" / "project_hooks.json" if hook_home else None + if template_path and template_path.is_file(): + template_data = json.loads(template_path.read_text(encoding="utf-8")) + if hooks_json_path.exists(): + try: + existing_hooks = json.loads(hooks_json_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + logger.info("hooks.json parse failed, rebuilding: %s", exc) + existing_hooks = {} + merged_hooks = _merge_hooks_json(existing_hooks, template_data) + merged_hooks_content = json.dumps(merged_hooks, indent=2, ensure_ascii=False) + "\n" + if existing_hooks != merged_hooks: + hooks_json_path.write_text(merged_hooks_content, encoding="utf-8") + updated.append(str(hooks_json_path)) + else: + already_current.append(str(hooks_json_path)) + else: + hooks_json_path.write_text( + json.dumps(template_data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + updated.append(str(hooks_json_path)) + elif hooks_json_path.exists(): + already_current.append(str(hooks_json_path)) + # CLAUDE.md, AGENTS.md — sync from project templates or AIPass source for md_name in ("CLAUDE.md", "AGENTS.md"): dest = target / md_name @@ -558,12 +583,6 @@ def update_project(target: Path) -> dict: else: already_current.append(str(prep_path)) - # Re-sync enforcement + injector hooks from AIPass install - hook_home = aipass_home or _detect_aipass_home() - if hook_home: - shipped = _ship_hooks(hook_home, target) - updated.extend(shipped) - # --- User-owned files: always skip --- for skip_name in ( str(registry_path), diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index ec47e97d..15b67944 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -6,11 +6,7 @@ # Modified: 2026-04-16 # ============================================= -""" -aipass doctor — system health aggregation - -Run: aipass doctor [--verbose] [--fix] [--fix --json] -""" +"""aipass doctor — system health aggregation.""" from __future__ import annotations @@ -60,10 +56,6 @@ from aipass.aipass.apps.handlers.ui.progress import ( make_doctor_progress, ) -# ============================================================================= -# TYPES -# ============================================================================= - _BRANCH_ROOT = Path(__file__).resolve().parents[2] @@ -76,9 +68,7 @@ class CheckResult(NamedTuple): remediation: str -# ============================================================================= -# IDENTITY HELPERS -# ============================================================================= +# --- Identity helpers --- def _find_registry() -> Path | None: @@ -97,9 +87,7 @@ def _find_registry() -> Path | None: return None -# ============================================================================= -# CHECK GROUPS -# ============================================================================= +# --- Check groups --- def _check_system() -> List[CheckResult]: @@ -201,6 +189,20 @@ def _check_identity() -> List[CheckResult]: else: results.append(CheckResult("registry valid", GLYPH_FAIL, "missing 'branches' key", "Re-run 'aipass init'")) + # hooks.json presence (project-level hook config) + hooks_json = reg_path.parent / ".aipass" / "hooks.json" + if hooks_json.exists(): + results.append(CheckResult("hooks.json", GLYPH_PASS, "present", "")) + else: + results.append( + CheckResult( + "hooks.json", + GLYPH_WARN, + "not found", + "Run 'aipass init update' to create .aipass/hooks.json", + ) + ) + # Passport readable passport = _BRANCH_ROOT / ".trinity" / "passport.json" if passport.exists(): @@ -478,9 +480,7 @@ def _check_community() -> List[CheckResult]: return results -# ============================================================================= -# STRUCTURE CHECK GROUP -# ============================================================================= +# --- Structure check group --- def _check_structure() -> List[CheckResult]: @@ -558,9 +558,7 @@ def _check_structure() -> List[CheckResult]: return results -# ============================================================================= -# MAIN DOCTOR RUN -# ============================================================================= +# --- Main doctor run --- def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = False) -> int: @@ -620,9 +618,7 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal return error_count -# ============================================================================= -# OUTPUT FORMATTING -# ============================================================================= +# --- Output formatting --- def print_introspection() -> None: @@ -652,9 +648,7 @@ def print_help() -> None: console.print() -# ============================================================================= -# COMMAND HANDLER -# ============================================================================= +# --- Command handler --- def handle_command(command: str, args: list[str]) -> bool: diff --git a/src/aipass/aipass/tests/test_bootstrap.py b/src/aipass/aipass/tests/test_bootstrap.py index 464c9d7e..ce01f749 100644 --- a/src/aipass/aipass/tests/test_bootstrap.py +++ b/src/aipass/aipass/tests/test_bootstrap.py @@ -22,6 +22,7 @@ import pytest # pyright: ignore[reportMissingImports] from aipass.aipass.apps.handlers.init import scaffold_content as sc from aipass.aipass.apps.handlers.init.bootstrap import ( + _merge_hooks_json, _sanitize_name, init_project, update_project, @@ -126,7 +127,7 @@ def test_init_project_creates_all_expected_files(tmp_path): created_basenames = [Path(f).name for f in result["created_files"]] for f in expected_files: assert f.name in created_basenames or f.exists(), f"Expected {f.name} in created_files" - assert len(result["created_files"]) >= 11 + assert len(result["created_files"]) >= 12 def test_init_project_return_dict_structure(tmp_path): @@ -324,7 +325,7 @@ def test_init_project_auto_creates_target_dir(tmp_path): assert target.is_dir() assert result["project_name"] == "NESTED" - assert len(result["created_files"]) >= 11 + assert len(result["created_files"]) >= 12 def test_init_project_defaults_name_from_directory(tmp_path): @@ -470,7 +471,7 @@ def test_update_project_already_current_after_init(tmp_path): result = update_project(target) assert len(result["updated_files"]) == 0 - assert len(result["already_current"]) >= 4 + assert len(result["already_current"]) >= 5 def test_update_project_idempotent(tmp_path): @@ -549,8 +550,8 @@ def test_update_project_creates_missing_managed_dirs(tmp_path): assert (target / ".aipass" / "aipass_global_prompt.md").exists() assert (target / ".claude" / "settings.json").exists() - # Managed files in deleted dirs re-written (global_prompt, settings, prep) - assert len(result["updated_files"]) == 3 + # Managed files in deleted dirs re-written (global_prompt, hooks.json, settings, prep) + assert len(result["updated_files"]) == 4 assert len(result["already_current"]) >= 2 @@ -629,7 +630,7 @@ def test_update_project_adds_aipass_home_if_missing(tmp_path): # --------------------------------------------------------------------------- -# DPLAN-0139: Hook shipping + /memo tests +# DPLAN-0190: hooks.json + /memo tests # --------------------------------------------------------------------------- @@ -644,8 +645,8 @@ def test_init_project_no_memo_md(tmp_path): assert not memo_path.exists() -def test_init_project_ships_hooks(tmp_path): - """init_project copies enforcement + injector hooks to target .claude/hooks/.""" +def test_init_project_creates_hooks_json(tmp_path): + """init_project creates .aipass/hooks.json from project_hooks.json template.""" target = tmp_path / "proj" target.mkdir() @@ -654,16 +655,46 @@ def test_init_project_ships_hooks(tmp_path): if result["aipass_home"] is None: pytest.skip("AIPASS_HOME not detectable in this environment") - hooks_dir = target / ".claude" / "hooks" - # Post DPLAN-0184: hooks are native handlers in src/aipass/hooks/, - # no longer shipped as script copies. Directory may or may not exist. - if hooks_dir.exists(): - shipped = [f.name for f in hooks_dir.iterdir()] - assert len(shipped) == 0, f"No hook scripts should be shipped post-migration: {shipped}" + hooks_json = target / ".aipass" / "hooks.json" + assert hooks_json.exists(), ".aipass/hooks.json should be created" + data = json.loads(hooks_json.read_text(encoding="utf-8")) + assert data.get("hooks_enabled") is True + assert "UserPromptSubmit" in data -def test_init_project_hooks_not_shipped_without_aipass_home(tmp_path, monkeypatch): - """When AIPASS_HOME is not detectable, hooks are not shipped.""" +def test_init_project_hooks_json_matches_template(tmp_path): + """hooks.json content matches the project_hooks.json template.""" + target = tmp_path / "proj" + target.mkdir() + + result = init_project(target, project_name="tmpl") + + if result["aipass_home"] is None: + pytest.skip("AIPASS_HOME not detectable in this environment") + + hooks_json = target / ".aipass" / "hooks.json" + template = Path(result["aipass_home"]) / ".aipass" / "project_hooks.json" + if not template.exists(): + pytest.skip("project_hooks.json template not found") + + assert hooks_json.read_bytes() == template.read_bytes() + + +def test_init_project_hooks_json_in_created_files(tmp_path): + """hooks.json path appears in created_files list.""" + target = tmp_path / "proj" + target.mkdir() + + result = init_project(target, project_name="created") + + if result["aipass_home"] is None: + pytest.skip("AIPASS_HOME not detectable in this environment") + + assert any("hooks.json" in f for f in result["created_files"]) + + +def test_init_project_no_hooks_json_without_aipass_home(tmp_path, monkeypatch): + """When AIPASS_HOME is not detectable, hooks.json is not created.""" target = tmp_path / "proj" target.mkdir() @@ -674,55 +705,20 @@ def test_init_project_hooks_not_shipped_without_aipass_home(tmp_path, monkeypatc init_project(target, project_name="nohooks") - hooks_dir = target / ".claude" / "hooks" - assert not hooks_dir.exists() or len(list(hooks_dir.iterdir())) == 0 + assert not (target / ".aipass" / "hooks.json").exists() -def test_init_project_no_audio_hooks_shipped(tmp_path): - """Audio hooks (notification_sound, tool_use_sound, stop_sound) are never shipped.""" +def test_init_project_no_hook_scripts_shipped(tmp_path): + """No hook scripts are shipped to .claude/hooks/ (engine runs from $AIPASS_HOME).""" target = tmp_path / "proj" target.mkdir() - init_project(target, project_name="noaudio") + init_project(target, project_name="noscripts") hooks_dir = target / ".claude" / "hooks" if hooks_dir.exists(): shipped = [f.name for f in hooks_dir.iterdir()] - assert "notification_sound.py" not in shipped - assert "tool_use_sound.py" not in shipped - assert "stop_sound.py" not in shipped - - -def test_update_project_resyncs_hooks(tmp_path): - """update_project re-copies hooks when source differs from target.""" - target = tmp_path / "proj" - target.mkdir() - result = init_project(target, project_name="resync") - - if result["aipass_home"] is None: - pytest.skip("AIPASS_HOME not detectable in this environment") - - # Post DPLAN-0184: hooks are native handlers, not shipped as copies. - # update_project no longer resyncs hook scripts. - result = update_project(target) - hooks_marker = str(Path(".claude") / "hooks") - hook_paths = [f for f in result["updated_files"] if hooks_marker in f] - assert len(hook_paths) == 0, "No hook scripts should be shipped post-migration" - - -def test_init_project_hooks_idempotent_on_rerun(tmp_path): - """Re-running init does not create hook script copies.""" - target = tmp_path / "proj" - target.mkdir() - - result1 = init_project(target, project_name="idem") - - if result1["aipass_home"] is None: - pytest.skip("AIPASS_HOME not detectable in this environment") - - hooks_marker = str(Path(".claude") / "hooks") - hook_paths = [f for f in result1["created_files"] if hooks_marker in f] - assert len(hook_paths) == 0, "No hook scripts should be shipped post-migration" + assert len(shipped) == 0, f"No hook scripts should be shipped: {shipped}" def test_init_project_settings_has_no_hook_events(tmp_path): @@ -736,6 +732,130 @@ def test_init_project_settings_has_no_hook_events(tmp_path): assert "hooks" not in settings +def test_update_project_creates_hooks_json_if_missing(tmp_path): + """update_project creates hooks.json from template when missing.""" + target = tmp_path / "proj" + target.mkdir() + + registry_data = { + "metadata": { + "id": "test-id", + "name": "MISS", + "version": "1.0.0", + "created": "2026-01-01", + "last_updated": "2026-01-01", + "total_branches": 0, + }, + "branches": [], + } + (target / "MISS_REGISTRY.json").write_text(json.dumps(registry_data), encoding="utf-8") + + hooks_json = target / ".aipass" / "hooks.json" + assert not hooks_json.exists() + + result = update_project(target) + + if result["aipass_home"] is None: + pytest.skip("AIPASS_HOME not detectable in this environment") + + assert hooks_json.exists() + assert any("hooks.json" in f for f in result["updated_files"]) + data = json.loads(hooks_json.read_text(encoding="utf-8")) + assert data.get("hooks_enabled") is True + + +def test_update_project_union_merge_preserves_user_enabled(tmp_path): + """update preserves user's enabled=false for existing hooks.""" + target = tmp_path / "proj" + target.mkdir() + result = init_project(target, project_name="merge") + + if result["aipass_home"] is None: + pytest.skip("AIPASS_HOME not detectable in this environment") + + hooks_json = target / ".aipass" / "hooks.json" + data = json.loads(hooks_json.read_text(encoding="utf-8")) + data["PreToolUse"]["git_gate"]["enabled"] = False + hooks_json.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + update_project(target) + + updated = json.loads(hooks_json.read_text(encoding="utf-8")) + assert updated["PreToolUse"]["git_gate"]["enabled"] is False + + +def test_update_project_union_merge_adds_new_hooks(tmp_path): + """update adds hooks from template that user doesn't have.""" + existing = { + "hooks_enabled": True, + "UserPromptSubmit": { + "identity_injector": {"enabled": True, "handler": "old.handler", "matcher": ""}, + }, + } + template = { + "hooks_enabled": True, + "UserPromptSubmit": { + "identity_injector": {"enabled": True, "handler": "new.handler", "matcher": ""}, + "brand_new_hook": {"enabled": True, "handler": "brand.new", "matcher": ""}, + }, + "Stop": { + "stop_sound": {"enabled": True, "handler": "stop.handler", "matcher": ""}, + }, + } + + merged = _merge_hooks_json(existing, template) + + assert "brand_new_hook" in merged["UserPromptSubmit"] + assert "Stop" in merged + assert merged["Stop"]["stop_sound"]["enabled"] is True + + +def test_update_project_union_merge_preserves_user_hooks(): + """User's custom hooks not in template are kept.""" + existing = { + "hooks_enabled": True, + "UserPromptSubmit": { + "my_custom_hook": {"enabled": True, "handler": "custom.handler", "matcher": ""}, + }, + } + template = { + "hooks_enabled": True, + "UserPromptSubmit": { + "identity_injector": {"enabled": True, "handler": "std.handler", "matcher": ""}, + }, + } + + merged = _merge_hooks_json(existing, template) + + assert "my_custom_hook" in merged["UserPromptSubmit"] + assert "identity_injector" in merged["UserPromptSubmit"] + + +def test_merge_hooks_json_preserves_hooks_enabled_false(): + """User's hooks_enabled=false is preserved over template's true.""" + existing = {"hooks_enabled": False} + template = {"hooks_enabled": True, "Stop": {"s": {"enabled": True, "handler": "h", "matcher": ""}}} + + merged = _merge_hooks_json(existing, template) + + assert merged["hooks_enabled"] is False + + +def test_update_project_hooks_json_already_current(tmp_path): + """update reports hooks.json as already_current when unchanged.""" + target = tmp_path / "proj" + target.mkdir() + result = init_project(target, project_name="curr") + + if result["aipass_home"] is None: + pytest.skip("AIPASS_HOME not detectable in this environment") + + result = update_project(target) + + assert not any("hooks.json" in f for f in result["updated_files"]) + assert any("hooks.json" in f for f in result["already_current"]) + + # --------------------------------------------------------------------------- # scaffold_content — global_prompt_md tests # --------------------------------------------------------------------------- diff --git a/src/aipass/aipass/tests/test_doctor.py b/src/aipass/aipass/tests/test_doctor.py index 7e7a453a..b7057f85 100644 --- a/src/aipass/aipass/tests/test_doctor.py +++ b/src/aipass/aipass/tests/test_doctor.py @@ -441,10 +441,8 @@ class TestProviderManifest: manifest = tmp_path / ".claude" / "provider_manifest.json" manifest.parent.mkdir(parents=True) - cmd_a = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop" - cmd_b = ( - "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification" - ) + cmd_a = "$AIPASS_HOME/bin/hook-bridge Stop" + cmd_b = "$AIPASS_HOME/bin/hook-bridge Notification" manifest.write_text( json.dumps( { @@ -487,7 +485,7 @@ class TestProviderManifest: manifest = tmp_path / ".claude" / "provider_manifest.json" manifest.parent.mkdir(parents=True) - cmd = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop" + cmd = "$AIPASS_HOME/bin/hook-bridge Stop" manifest.write_text( json.dumps( { @@ -573,3 +571,44 @@ class TestProviderManifest: result = _find_manifest() assert result is not None assert result == manifest + + +# ============================================================================= +# TestHooksJsonCheck +# ============================================================================= + + +class TestHooksJsonCheck: + """Tests for hooks.json presence check in _check_identity (DPLAN-0190).""" + + def test_hooks_json_present_returns_pass(self, tmp_path) -> None: + """When .aipass/hooks.json exists, check returns PASS.""" + from aipass.aipass.apps.modules.doctor import _check_identity + + registry = tmp_path / "TEST_REGISTRY.json" + registry.write_text(json.dumps({"metadata": {"id": "t"}, "branches": []}), encoding="utf-8") + hooks_dir = tmp_path / ".aipass" + hooks_dir.mkdir() + (hooks_dir / "hooks.json").write_text('{"hooks_enabled": true}', encoding="utf-8") + + with patch("aipass.aipass.apps.modules.doctor._find_registry", return_value=registry): + results = _check_identity() + + hooks_results = [r for r in results if r.label == "hooks.json"] + assert len(hooks_results) == 1 + assert hooks_results[0].glyph == GLYPH_PASS + + def test_hooks_json_missing_returns_warn(self, tmp_path) -> None: + """When .aipass/hooks.json is absent, check returns WARN.""" + from aipass.aipass.apps.modules.doctor import _check_identity + + registry = tmp_path / "TEST_REGISTRY.json" + registry.write_text(json.dumps({"metadata": {"id": "t"}, "branches": []}), encoding="utf-8") + + with patch("aipass.aipass.apps.modules.doctor._find_registry", return_value=registry): + results = _check_identity() + + hooks_results = [r for r in results if r.label == "hooks.json"] + assert len(hooks_results) == 1 + assert hooks_results[0].glyph == GLYPH_WARN + assert "init update" in hooks_results[0].remediation