feat(cli): feat(cli): bootstrap ships hooks + /memo to new projects (DPLAN-0139 Track D)
Co-Authored-By: @cli <cli@aipass>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user