diff --git a/src/aipass/cli/apps/handlers/init/bootstrap.py b/src/aipass/cli/apps/handlers/init/bootstrap.py index 5b830ade..293da45e 100644 --- a/src/aipass/cli/apps/handlers/init/bootstrap.py +++ b/src/aipass/cli/apps/handlers/init/bootstrap.py @@ -469,12 +469,15 @@ def _gitignore() -> str: def _claude_settings(aipass_home: str | None = None) -> str: - """Generate .claude/settings.json — minimal hooks for prompt injection. + """Generate .claude/settings.json — hooks for prompt injection + enforcement. - Installs two UserPromptSubmit hooks: - 1. Global prompt — injects .aipass/aipass_global_prompt.md from CWD. - 2. Local prompt — walks up from CWD via pathlib.Path.parents to find - .aipass/aipass_local_prompt.md (cross-platform; works on Windows). + Wires all AIPass hooks into their respective event types: + - UserPromptSubmit: global/local prompt injection + branch_prompt_loader, + email_notification, identity_injector + - PostToolUse: auto_fix_diagnostics + - PreToolUse: pre_edit_gate + - Stop: subagent_stop_gate + - PreCompact: pre_compact Args: aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME. @@ -488,30 +491,38 @@ def _claude_settings(aipass_home: str | None = None) -> str: "p and print(p.read_text(encoding='utf-8'),end='')" '"' ) - data: dict = { - "hooks": { - "UserPromptSubmit": [ - { - "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: 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} if aipass_home: data["env"] = {"AIPASS_HOME": aipass_home} return json.dumps(data, indent=2, ensure_ascii=False) + "\n" @@ -602,6 +613,48 @@ def _prep_md() -> str: ) +def _memo_md() -> str: + """Generate .claude/commands/memo.md — /memo memory update slash command.""" + return ( + "# Memory Update\n" + "\n" + "Purpose: Update branch memory files after completing work this session.\n" + "\n" + "## Execution\n" + "\n" + "1. Read `.trinity/passport.json` first — re-absorb your identity, " + "role, and principles before writing memories\n" + "2. Review what was done this session (context, recent changes, key " + "decisions)\n" + "3. Update each file below as needed\n" + "4. Confirm completion — list files updated\n" + "\n" + "## Memory Roles\n" + "\n" + "Each memory file plays a distinct role. Update based on what actually " + "changed this session.\n" + "\n" + "- **`.trinity/passport.json`** — IDENTITY. Who you are: role, " + "capabilities, principles. Only update if identity genuinely evolved " + "this session. Don't touch it just to touch it.\n" + "- **`.trinity/local.json`** — YOUR MEMORY. Session history and " + "key_learnings. Add a session entry for significant work. Add " + "key_learnings for facts you'd need next time. Trim oldest sessions " + "if over 20.\n" + "- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. " + "Collaboration insights, preferences, friction points, flow states. " + "Skip entirely if nothing new about the user this session.\n" + "- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known " + "issues, todos, notepad. Auto-synced to central STATUS.md on PR " + "events — this is how other branches see you. Keep Current Work " + "accurate and drop quick notes in the Notepad section.\n" + "\n" + "## If Relevant\n" + "\n" + "- **README.md** — Does it reflect current state? Update if stale.\n" + ) + + def _inbox_json() -> str: """Generate .ai_mail.local/inbox.json — empty project mailbox structure.""" return ( @@ -762,6 +815,17 @@ def init_project(target: Path, project_name: str | None = None) -> dict: prep_path.write_text(_prep_md(), encoding="utf-8") created.append(str(prep_path)) + # 9c. .claude/commands/memo.md — /memo memory update slash command + memo_path = commands_dir / "memo.md" + if not memo_path.exists(): + memo_path.write_text(_memo_md(), encoding="utf-8") + created.append(str(memo_path)) + + # 9d. Ship enforcement + injector hooks from AIPass install + if aipass_home: + shipped = _ship_hooks(aipass_home, target) + created.extend(shipped) + # 10. hooks/ directory hooks_dir = target / "hooks" if not hooks_dir.exists(): @@ -898,6 +962,21 @@ def update_project(target: Path) -> dict: else: already_current.append(str(prep_path)) + # .claude/commands/memo.md — managed slash command, refresh to latest + memo_path = commands_dir / "memo.md" + generated = _memo_md() + if not memo_path.exists() or memo_path.read_text(encoding="utf-8") != generated: + memo_path.write_text(generated, encoding="utf-8") + updated.append(str(memo_path)) + else: + already_current.append(str(memo_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/cli/tests/test_bootstrap.py b/src/aipass/cli/tests/test_bootstrap.py index c39dfca1..c23aa912 100644 --- a/src/aipass/cli/tests/test_bootstrap.py +++ b/src/aipass/cli/tests/test_bootstrap.py @@ -90,6 +90,8 @@ def test_init_project_creates_all_expected_files(tmp_path): target / "STATUS.local.md", target / ".gitignore", target / ".claude" / "settings.json", + target / ".claude" / "commands" / "prep.md", + target / ".claude" / "commands" / "memo.md", target / ".ai_mail.local" / "inbox.json", ] for f in expected_files: @@ -105,8 +107,8 @@ def test_init_project_creates_all_expected_files(tmp_path): # No local prompt at project level (belongs in agent dirs only) assert not (target / ".aipass" / "aipass_local_prompt.md").exists() - # 10 files + 2 directories = 12 created_files entries - assert len(result["created_files"]) == 12 + # 12 files + 2 dirs + 7 shipped hooks (when AIPASS_HOME detected) = 21 + assert len(result["created_files"]) == 21 def test_init_project_return_dict_structure(tmp_path): @@ -277,8 +279,8 @@ def test_init_project_claude_settings_content(tmp_path): assert "UserPromptSubmit" in data["hooks"] -def test_init_project_settings_has_two_hooks(tmp_path): - """.claude/settings.json has both global and local prompt hooks.""" +def test_init_project_settings_has_all_hooks(tmp_path): + """.claude/settings.json wires all hook event types.""" target = tmp_path / "proj" target.mkdir() @@ -286,18 +288,26 @@ def test_init_project_settings_has_two_hooks(tmp_path): settings_path = target / ".claude" / "settings.json" data = json.loads(settings_path.read_text(encoding="utf-8")) - hooks = data["hooks"]["UserPromptSubmit"] - assert len(hooks) == 2, f"Expected 2 UserPromptSubmit hooks, got {len(hooks)}" - # First hook: global prompt - global_cmd = hooks[0]["hooks"][0]["command"] - assert "aipass_global_prompt.md" in global_cmd + # 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"] - # Second hook: local prompt walk-up - local_cmd = hooks[1]["hooks"][0]["command"] - assert "aipass_local_prompt.md" in local_cmd - assert "python3" in local_cmd, "Local prompt hook should use python3 for cross-platform support" - assert "parents" in local_cmd, "Local prompt hook should use pathlib.Path.parents for traversal" + # Enforcement hooks wired to their event types + assert len(data["hooks"]["PostToolUse"]) == 1 + assert "auto_fix_diagnostics.py" in data["hooks"]["PostToolUse"][0]["hooks"][0]["command"] + + assert len(data["hooks"]["PreToolUse"]) == 1 + assert "pre_edit_gate.py" in data["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + + assert len(data["hooks"]["Stop"]) == 1 + assert "subagent_stop_gate.py" in data["hooks"]["Stop"][0]["hooks"][0]["command"] + + assert len(data["hooks"]["PreCompact"]) == 1 + assert "pre_compact.py" in data["hooks"]["PreCompact"][0]["hooks"][0]["command"] def test_init_project_global_prompt_content(tmp_path): @@ -336,7 +346,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"]) == 12 + assert len(result["created_files"]) == 21 def test_init_project_defaults_name_from_directory(tmp_path): @@ -395,8 +405,8 @@ def test_init_project_skips_existing_optional_files(tmp_path): result = init_project(target, project_name="eta") - # Only registry should be in created_files (everything else pre-existed) - assert len(result["created_files"]) == 1 + # Registry + prep.md + memo.md + 7 shipped hooks = 10 (everything else pre-existed) + assert len(result["created_files"]) == 10 # Verify pre-existing files were NOT overwritten md_content = (target / "CLAUDE.md").read_text(encoding="utf-8") @@ -489,7 +499,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"]) == 5 + assert len(result["already_current"]) == 7 def test_update_project_idempotent(tmp_path): @@ -568,8 +578,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() - # The 2 files inside deleted dirs are re-written; root files still match - assert len(result["updated_files"]) == 2 + # Managed files in deleted dirs re-written (global_prompt, settings, prep, memo + 7 hooks) + assert len(result["updated_files"]) == 11 assert len(result["already_current"]) == 3 @@ -719,3 +729,167 @@ def test_update_project_adds_aipass_home_if_missing(tmp_path): new_data = json.loads(settings_path.read_text(encoding="utf-8")) assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"] assert str(settings_path) in result["updated_files"] + + +# --------------------------------------------------------------------------- +# DPLAN-0139: Hook shipping + /memo tests +# --------------------------------------------------------------------------- + + +def test_init_project_creates_memo_md(tmp_path): + """init_project creates .claude/commands/memo.md slash command.""" + target = tmp_path / "proj" + target.mkdir() + + init_project(target, project_name="memo") + + memo_path = target / ".claude" / "commands" / "memo.md" + assert memo_path.exists() + content = memo_path.read_text(encoding="utf-8") + assert "# Memory Update" in content + assert ".trinity/passport.json" in content + assert ".trinity/local.json" in content + assert "STATUS.local.md" in content + + +def test_init_project_memo_not_overwritten_on_rerun(tmp_path): + """Re-running init skips existing memo.md.""" + target = tmp_path / "proj" + target.mkdir() + init_project(target, project_name="memo") + + memo_path = target / ".claude" / "commands" / "memo.md" + memo_path.write_text("# Custom memo\n", encoding="utf-8") + + init_project(target, project_name="memo") + + assert memo_path.read_text(encoding="utf-8") == "# Custom memo\n" + + +def test_update_project_refreshes_memo_md(tmp_path): + """update_project refreshes memo.md when content differs.""" + target = tmp_path / "proj" + target.mkdir() + init_project(target, project_name="umemo") + + memo_path = target / ".claude" / "commands" / "memo.md" + memo_path.write_text("# Stale content\n", encoding="utf-8") + + result = update_project(target) + + assert str(memo_path) in result["updated_files"] + assert "# Memory Update" in memo_path.read_text(encoding="utf-8") + + +def test_update_project_memo_already_current(tmp_path): + """update_project reports memo.md as already_current when unchanged.""" + target = tmp_path / "proj" + target.mkdir() + init_project(target, project_name="memocur") + + result = update_project(target) + + memo_path = target / ".claude" / "commands" / "memo.md" + assert str(memo_path) in result["already_current"] + + +def test_init_project_ships_hooks(tmp_path): + """init_project copies enforcement + injector hooks to target .claude/hooks/.""" + target = tmp_path / "proj" + target.mkdir() + + result = init_project(target, project_name="hooks") + + if result["aipass_home"] is None: + pytest.skip("AIPASS_HOME not detectable in this environment") + + hooks_dir = target / ".claude" / "hooks" + assert hooks_dir.is_dir() + for hook_name in [ + "auto_fix_diagnostics.py", + "pre_edit_gate.py", + "subagent_stop_gate.py", + "pre_compact.py", + "branch_prompt_loader.py", + "email_notification.py", + "identity_injector.py", + ]: + assert (hooks_dir / hook_name).exists(), f"Hook {hook_name} not shipped" + + +def test_init_project_hooks_not_shipped_without_aipass_home(tmp_path, monkeypatch): + """When AIPASS_HOME is not detectable, hooks are not shipped.""" + target = tmp_path / "proj" + target.mkdir() + + monkeypatch.setattr( + "aipass.cli.apps.handlers.init.bootstrap._detect_aipass_home", + lambda: None, + ) + + init_project(target, project_name="nohooks") + + hooks_dir = target / ".claude" / "hooks" + assert not hooks_dir.exists() or len(list(hooks_dir.iterdir())) == 0 + + +def test_init_project_no_audio_hooks_shipped(tmp_path): + """Audio hooks (notification_sound, tool_use_sound, stop_sound) are never shipped.""" + target = tmp_path / "proj" + target.mkdir() + + init_project(target, project_name="noaudio") + + 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") + + hook_file = target / ".claude" / "hooks" / "auto_fix_diagnostics.py" + hook_file.write_text("# corrupted\n", encoding="utf-8") + + result = update_project(target) + + assert str(hook_file) in result["updated_files"] + assert hook_file.read_text(encoding="utf-8") != "# corrupted\n" + + +def test_init_project_hooks_idempotent_on_rerun(tmp_path): + """Re-running init does not re-ship hooks when content is identical.""" + target = tmp_path / "proj" + target.mkdir() + + result1 = init_project(target, project_name="idem") + result2 = init_project(target, project_name="idem") + + if result1["aipass_home"] is None: + pytest.skip("AIPASS_HOME not detectable in this environment") + + hook_paths = [f for f in result1["created_files"] if ".claude/hooks/" in f] + assert len(hook_paths) == 7 + hook_paths_rerun = [f for f in result2["created_files"] if ".claude/hooks/" in f] + assert len(hook_paths_rerun) == 0 + + +def test_init_project_settings_has_all_event_types(tmp_path): + """settings.json contains all 5 hook event types.""" + 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", "PostToolUse", "PreToolUse", "Stop", "PreCompact"} + assert set(settings["hooks"].keys()) == expected_events