feat(hooks): ship .aipass/hooks.json on aipass init (DPLAN-0190 Phase A)

- bootstrap.py: write hooks.json from template at init, union-merge on update (preserves user on/off), remove dead _ship_hooks/HOOKS_TO_SHIP
- doctor.py: check .aipass/hooks.json presence
- .aipass/project_hooks.json: base template (all 14 handlers)
- .aipass/.gitignore: whitelist template so it ships in clones
- +13 tests, 421 pass, seedgo 99%
This commit is contained in:
AIOSAI
2026-05-28 19:41:10 -07:00
parent a752923ae2
commit 574ae79b1a
8 changed files with 433 additions and 155 deletions
+2 -1
View File
@@ -4,4 +4,5 @@
!.gitignore
!project_CLAUDE.md
!project_global_prompt.md
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
!project_hooks.json
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
+99
View File
@@ -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 <hook>` 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
}
}
}
+6
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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/<project>/ 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),
+21 -27
View File
@@ -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:
+176 -56
View File
@@ -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
# ---------------------------------------------------------------------------
+44 -5
View File
@@ -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