diff --git a/.gitignore b/.gitignore index 238162e..b62e1c0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,12 @@ build/ *.egg-info/ .omx/ +# runtime data (agent-learner, hermit, codex) +.agent-learner/ +.codex/ +.hermit/ +frontend/package-lock.json + node_modules/ frontend/node_modules/ .idea/ diff --git a/src/agent_learner/adapters/claude.py b/src/agent_learner/adapters/claude.py index 78dbf4f..d2555fb 100644 --- a/src/agent_learner/adapters/claude.py +++ b/src/agent_learner/adapters/claude.py @@ -3,7 +3,7 @@ import shlex from pathlib import Path -from .common import ensure_dir, merge_json_file, write_text +from .common import ensure_dir, merge_json_file, upsert_hook, write_text AUTO_SESSION_LEARNING = """#!/usr/bin/env python3 @@ -146,3 +146,29 @@ def install_claude_adapter_with_scope(target_root: Path, *, scope: str = "projec def install_claude_adapter(target_root: Path) -> list[Path]: return install_claude_adapter_with_scope(target_root, scope="project") + + +# --------------------------------------------------------------------------- +# Phase 2: lightweight hook installer (mirrors hermit adapter pattern) +# --------------------------------------------------------------------------- + +_CLAUDE_HOOK_COMMAND = ( + "agent-learner process --adapter claude" + " --session-id $CLAUDE_SESSION_ID" + " --cwd $CWD" + " --model-id $CLAUDE_MODEL" + " --auto" +) + + +def install_claude_hooks(project_root: Path, *, scope: str = "project") -> Path: + """ + Install agent-learner Stop hook into .claude/settings.json. + + - scope="project": {project_root}/.claude/settings.json + - scope="user": {project_root}/.claude/settings.json (caller routes to home) + - Idempotent: updates existing agent-learner hook if present. + - Preserves other Stop hooks. + """ + settings_path = project_root / ".claude" / "settings.json" + return upsert_hook(settings_path, "Stop", _CLAUDE_HOOK_COMMAND) diff --git a/src/agent_learner/adapters/codex.py b/src/agent_learner/adapters/codex.py index c3f74c4..2fd187a 100644 --- a/src/agent_learner/adapters/codex.py +++ b/src/agent_learner/adapters/codex.py @@ -3,7 +3,7 @@ import shlex from pathlib import Path -from .common import append_lines_if_missing, ensure_dir, merge_json_file, write_text +from .common import append_lines_if_missing, ensure_dir, merge_json_file, upsert_hook, write_text from agent_learner.core.storage import migrate_legacy_learning_assets @@ -336,3 +336,29 @@ def install_codex_adapter_with_scope(target_root: Path, *, scope: str = "project if scope == "project": written.extend(migrate_legacy_learning_assets(target_root)) return written + + +# --------------------------------------------------------------------------- +# Phase 2: lightweight hook installer (mirrors hermit adapter pattern) +# --------------------------------------------------------------------------- + +_CODEX_HOOK_COMMAND = ( + "agent-learner process --adapter codex" + " --session-id $CODEX_SESSION_ID" + " --cwd $CWD" + " --model-id $CODEX_MODEL" + " --auto" +) + + +def install_codex_hooks(project_root: Path, *, scope: str = "project") -> Path: + """ + Install agent-learner Stop hook into .codex/hooks.json. + + - scope="project": {project_root}/.codex/hooks.json + - scope="user": {project_root}/.codex/hooks.json (caller routes to home) + - Idempotent: updates existing agent-learner hook if present. + - Preserves other Stop hooks. + """ + hooks_path = project_root / ".codex" / "hooks.json" + return upsert_hook(hooks_path, "Stop", _CODEX_HOOK_COMMAND) diff --git a/src/agent_learner/adapters/common.py b/src/agent_learner/adapters/common.py index dc2e384..2cb3a0b 100644 --- a/src/agent_learner/adapters/common.py +++ b/src/agent_learner/adapters/common.py @@ -61,3 +61,44 @@ def merge_lists(existing: list, incoming: list) -> list: result.append(item) seen.add(key) return result + + +def is_agent_learner_hook(hook: dict) -> bool: + """Check whether a hook entry was installed by agent-learner.""" + return "agent-learner" in hook.get("command", "") + + +def upsert_hook(settings_path: Path, event_name: str, command: str) -> Path: + """Idempotently install or update an agent-learner hook in a JSON settings file. + + - Creates the file and parent directories if they do not exist. + - Replaces an existing agent-learner hook under *event_name* if found. + - Appends a new hook entry otherwise. + - Preserves all other hooks. + """ + ensure_dir(settings_path.parent) + + if settings_path.exists(): + data = json.loads(settings_path.read_text(encoding="utf-8")) + else: + data = {} + + hooks = data.setdefault("hooks", {}) + event_hooks: list[dict] = hooks.setdefault(event_name, []) + + new_hook = {"command": command} + replaced = False + for i, hook in enumerate(event_hooks): + if is_agent_learner_hook(hook): + event_hooks[i] = new_hook + replaced = True + break + if not replaced: + event_hooks.append(new_hook) + + hooks[event_name] = event_hooks + settings_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return settings_path diff --git a/src/agent_learner/adapters/hermit.py b/src/agent_learner/adapters/hermit.py new file mode 100644 index 0000000..b0d2ef7 --- /dev/null +++ b/src/agent_learner/adapters/hermit.py @@ -0,0 +1,67 @@ +"""Hermit adapter — install OnStop hooks and emit session events.""" +from __future__ import annotations + +from pathlib import Path + +from .common import upsert_hook +from agent_learner.core.events import build_learning_event, write_learning_event + +_ADAPTER = "hermit" + +_HOOK_COMMAND = ( + "agent-learner process --adapter hermit" + " --session-id $HERMIT_SESSION_ID" + " --cwd $CWD" + " --model-id $HERMIT_MODEL_ID" + " --auto" +) + + +def install_hermit_hooks(project_root: Path, *, scope: str = "project") -> Path: + """ + Install agent-learner OnStop hook into .hermit/settings.json. + + - scope="project": {project_root}/.hermit/settings.json + - scope="user": ~/.hermit/settings.json + - Idempotent: updates existing agent-learner hook if present. + - Preserves other OnStop hooks. + """ + settings_path = project_root / ".hermit" / "settings.json" + return upsert_hook(settings_path, "OnStop", _HOOK_COMMAND) + + +def emit_session_event( + project_root: Path, + *, + session_id: str, + cwd: str, + model_id: str, + outcome: str, + tool_call_count: int, + pytest_output: str | None = None, + transcript_path: str | None = None, +) -> Path: + """ + Write a session_end event to .agent-learner/events/hermit/. + + Wraps write_learning_event() from core. + """ + payload: dict[str, object] = { + "outcome": outcome, + "tool_call_count": tool_call_count, + "model_id": model_id, + } + if pytest_output is not None: + payload["pytest_output"] = pytest_output + if transcript_path is not None: + payload["transcript_path"] = transcript_path + + event = build_learning_event( + adapter=_ADAPTER, + event_name="session_end", + cwd=cwd, + session_id=session_id, + transcript_path=transcript_path, + payload=payload, + ) + return write_learning_event(project_root, event) diff --git a/src/agent_learner/cli/main.py b/src/agent_learner/cli/main.py index fd6d89c..3be803f 100644 --- a/src/agent_learner/cli/main.py +++ b/src/agent_learner/cli/main.py @@ -10,8 +10,9 @@ from pathlib import Path from agent_learner.adapters import install_claude_adapter, install_codex_adapter -from agent_learner.adapters.claude import install_claude_adapter_with_scope -from agent_learner.adapters.codex import install_codex_adapter_with_scope +from agent_learner.adapters.claude import install_claude_adapter_with_scope, install_claude_hooks +from agent_learner.adapters.codex import install_codex_adapter_with_scope, install_codex_hooks +from agent_learner.adapters.hermit import install_hermit_hooks from agent_learner.adapters.codex_context import ( build_codex_user_prompt_hook_output, format_retrieval_results_as_json, @@ -113,10 +114,11 @@ def build_parser() -> argparse.ArgumentParser: bootstrap_cmd.add_argument( "--adapters", default="codex,claude", - help="Comma-separated adapter list: codex, claude", + help="Comma-separated adapter list: codex, claude, hermit, or 'auto' to detect from existing dirs", ) bootstrap_cmd.add_argument("--codex-scope", choices=["project", "user"], default="project") bootstrap_cmd.add_argument("--claude-scope", choices=["project", "user"], default="user") + bootstrap_cmd.add_argument("--hermit-scope", choices=["project", "user"], default="project") promote_cmd = sub.add_parser("promote-demo") promote_cmd.add_argument("--root", default=".agent-learner") @@ -183,20 +185,20 @@ def build_parser() -> argparse.ArgumentParser: capture_cmd = sub.add_parser("capture-event") capture_cmd.add_argument("--project-root", default=".") - capture_cmd.add_argument("--adapter", required=True, choices=["codex", "claude"]) + capture_cmd.add_argument("--adapter", required=True, choices=["codex", "claude", "hermit"]) capture_cmd.add_argument("--event-name", required=True) capture_cmd.add_argument("--session-id") capture_cmd.add_argument("--transcript-path") - process_cmd = sub.add_parser("process-events") + process_cmd = sub.add_parser("process-events", aliases=["process"]) process_cmd.add_argument("--project-root", default=".") - process_cmd.add_argument("--adapter", choices=["codex", "claude"]) + process_cmd.add_argument("--adapter", choices=["codex", "claude", "hermit"]) process_cmd.add_argument("--limit", type=int) process_cmd.add_argument("--format", choices=["text", "json"], default="text") review_candidates_cmd = sub.add_parser("review-candidates") review_candidates_cmd.add_argument("--project-root", default=".") - review_candidates_cmd.add_argument("--adapter", choices=["codex", "claude"]) + review_candidates_cmd.add_argument("--adapter", choices=["codex", "claude", "hermit"]) review_candidates_cmd.add_argument("--format", choices=["text", "json"], default="text") review_candidate_cmd = sub.add_parser("review-candidate") @@ -332,13 +334,32 @@ def main() -> int: return 0 if args.command == "bootstrap": target = Path(args.target).resolve() - adapters = [item.strip() for item in args.adapters.split(",") if item.strip()] + adapters_raw = args.adapters + + # Auto-detect harnesses present in target directory + if adapters_raw == "auto": + detected: list[str] = [] + if (target / ".hermit").is_dir(): + detected.append("hermit") + if (target / ".claude").is_dir(): + detected.append("claude") + if (target / ".codex").is_dir(): + detected.append("codex") + adapters = detected + else: + adapters = [item.strip() for item in adapters_raw.split(",") if item.strip()] + written: list[Path] = [] + if "hermit" in adapters: + hermit_target = Path.home() if args.hermit_scope == "user" else target + written.append(install_hermit_hooks(hermit_target, scope=args.hermit_scope)) if "codex" in adapters: written.extend(install_codex_adapter_with_scope(target, scope=args.codex_scope)) + written.append(install_codex_hooks(target, scope=args.codex_scope)) if "claude" in adapters: claude_target = Path.home() if args.claude_scope == "user" else target written.extend(install_claude_adapter_with_scope(claude_target, scope=args.claude_scope)) + written.append(install_claude_hooks(claude_target, scope=args.claude_scope)) for path in dict.fromkeys(written): print(path) return 0 diff --git a/tests/test_bootstrap_cli.py b/tests/test_bootstrap_cli.py new file mode 100644 index 0000000..cdcb1a2 --- /dev/null +++ b/tests/test_bootstrap_cli.py @@ -0,0 +1,76 @@ +"""Tests for the bootstrap CLI command.""" +from __future__ import annotations + +import json +from pathlib import Path + +from agent_learner.cli.main import build_parser, main as cli_main + + +def _invoke(args: list[str]) -> int: + """Run CLI and return exit code.""" + parser = build_parser() + parsed = parser.parse_args(args) + # We need to call main() indirectly; use subprocess-free approach + import sys + old_argv = sys.argv + sys.argv = ["agent-learner"] + args + try: + return cli_main() + finally: + sys.argv = old_argv + + +def test_bootstrap_detects_hermit(tmp_path: Path) -> None: + (tmp_path / ".hermit").mkdir() + _invoke(["bootstrap", "--target", str(tmp_path), "--adapters", "auto"]) + settings_path = tmp_path / ".hermit" / "settings.json" + assert settings_path.exists() + settings = json.loads(settings_path.read_text(encoding="utf-8")) + assert any("agent-learner" in h["command"] for h in settings["hooks"]["OnStop"]) + + +def test_bootstrap_detects_claude(tmp_path: Path) -> None: + (tmp_path / ".claude").mkdir() + _invoke(["bootstrap", "--target", str(tmp_path), "--adapters", "auto", "--claude-scope", "project"]) + settings_path = tmp_path / ".claude" / "settings.json" + assert settings_path.exists() + + +def test_bootstrap_detects_codex(tmp_path: Path) -> None: + (tmp_path / ".codex").mkdir() + _invoke(["bootstrap", "--target", str(tmp_path), "--adapters", "auto"]) + hooks_path = tmp_path / ".codex" / "hooks.json" + assert hooks_path.exists() + + +def test_bootstrap_detects_all_three(tmp_path: Path) -> None: + (tmp_path / ".hermit").mkdir() + (tmp_path / ".claude").mkdir() + (tmp_path / ".codex").mkdir() + _invoke(["bootstrap", "--target", str(tmp_path), "--adapters", "auto", "--claude-scope", "project"]) + assert (tmp_path / ".hermit" / "settings.json").exists() + assert (tmp_path / ".claude" / "settings.json").exists() + assert (tmp_path / ".codex" / "hooks.json").exists() + + +def test_bootstrap_idempotent(tmp_path: Path) -> None: + (tmp_path / ".hermit").mkdir() + _invoke(["bootstrap", "--target", str(tmp_path), "--adapters", "auto"]) + _invoke(["bootstrap", "--target", str(tmp_path), "--adapters", "auto"]) + settings = json.loads((tmp_path / ".hermit" / "settings.json").read_text(encoding="utf-8")) + al_hooks = [h for h in settings["hooks"]["OnStop"] if "agent-learner" in h["command"]] + assert len(al_hooks) == 1 + + +def test_init_creates_directory_structure(tmp_path: Path) -> None: + _invoke(["init", "--root", str(tmp_path / ".agent-learner")]) + assert (tmp_path / ".agent-learner" / "approved").exists() + assert (tmp_path / ".agent-learner" / "drafts").exists() + + +def test_bootstrap_no_harness_creates_nothing(tmp_path: Path) -> None: + _invoke(["bootstrap", "--target", str(tmp_path), "--adapters", "auto"]) + assert not (tmp_path / ".hermit").exists() + assert not (tmp_path / ".claude").exists() + assert not (tmp_path / ".codex").exists() diff --git a/tests/test_claude_adapter.py b/tests/test_claude_adapter.py new file mode 100644 index 0000000..3dadf28 --- /dev/null +++ b/tests/test_claude_adapter.py @@ -0,0 +1,54 @@ +"""Tests for the refactored Claude adapter hook installation.""" +from __future__ import annotations + +import json +from pathlib import Path + +from agent_learner.adapters.claude import install_claude_hooks + + +def test_install_claude_hooks_creates_settings(tmp_path: Path) -> None: + install_claude_hooks(tmp_path, scope="project") + settings_path = tmp_path / ".claude" / "settings.json" + assert settings_path.exists() + settings = json.loads(settings_path.read_text(encoding="utf-8")) + stop_hooks = settings["hooks"]["Stop"] + assert any("agent-learner process" in h["command"] for h in stop_hooks) + + +def test_install_claude_hooks_idempotent(tmp_path: Path) -> None: + install_claude_hooks(tmp_path, scope="project") + install_claude_hooks(tmp_path, scope="project") + settings = json.loads((tmp_path / ".claude" / "settings.json").read_text(encoding="utf-8")) + al_hooks = [h for h in settings["hooks"]["Stop"] if "agent-learner" in h["command"]] + assert len(al_hooks) == 1 + + +def test_install_claude_hooks_preserves_existing(tmp_path: Path) -> None: + existing = {"hooks": {"Stop": [{"command": "my-existing-hook"}]}} + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude" / "settings.json").write_text(json.dumps(existing)) + install_claude_hooks(tmp_path, scope="project") + settings = json.loads((tmp_path / ".claude" / "settings.json").read_text(encoding="utf-8")) + commands = [h["command"] for h in settings["hooks"]["Stop"]] + assert "my-existing-hook" in commands + assert any("agent-learner" in c for c in commands) + + +def test_install_claude_hooks_command_format(tmp_path: Path) -> None: + install_claude_hooks(tmp_path, scope="project") + settings = json.loads((tmp_path / ".claude" / "settings.json").read_text(encoding="utf-8")) + hook = [h for h in settings["hooks"]["Stop"] if "agent-learner" in h["command"]][0] + assert "--adapter claude" in hook["command"] + assert "--session-id $CLAUDE_SESSION_ID" in hook["command"] + assert "--cwd $CWD" in hook["command"] + assert "--model-id $CLAUDE_MODEL" in hook["command"] + + +def test_install_claude_hooks_user_scope(tmp_path: Path) -> None: + home = tmp_path / "home" + install_claude_hooks(home, scope="user") + settings_path = home / ".claude" / "settings.json" + assert settings_path.exists() + settings = json.loads(settings_path.read_text(encoding="utf-8")) + assert any("agent-learner" in h["command"] for h in settings["hooks"]["Stop"]) diff --git a/tests/test_codex_adapter.py b/tests/test_codex_adapter.py new file mode 100644 index 0000000..5870e8c --- /dev/null +++ b/tests/test_codex_adapter.py @@ -0,0 +1,54 @@ +"""Tests for the refactored Codex adapter hook installation.""" +from __future__ import annotations + +import json +from pathlib import Path + +from agent_learner.adapters.codex import install_codex_hooks + + +def test_install_codex_hooks_creates_hooks_json(tmp_path: Path) -> None: + install_codex_hooks(tmp_path, scope="project") + hooks_path = tmp_path / ".codex" / "hooks.json" + assert hooks_path.exists() + hooks = json.loads(hooks_path.read_text(encoding="utf-8")) + stop_hooks = hooks["hooks"]["Stop"] + assert any("agent-learner process" in h["command"] for h in stop_hooks) + + +def test_install_codex_hooks_idempotent(tmp_path: Path) -> None: + install_codex_hooks(tmp_path, scope="project") + install_codex_hooks(tmp_path, scope="project") + hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8")) + al_hooks = [h for h in hooks["hooks"]["Stop"] if "agent-learner" in h["command"]] + assert len(al_hooks) == 1 + + +def test_install_codex_hooks_preserves_existing(tmp_path: Path) -> None: + existing = {"hooks": {"Stop": [{"command": "my-existing-hook"}]}} + (tmp_path / ".codex").mkdir() + (tmp_path / ".codex" / "hooks.json").write_text(json.dumps(existing)) + install_codex_hooks(tmp_path, scope="project") + hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8")) + commands = [h["command"] for h in hooks["hooks"]["Stop"]] + assert "my-existing-hook" in commands + assert any("agent-learner" in c for c in commands) + + +def test_install_codex_hooks_command_format(tmp_path: Path) -> None: + install_codex_hooks(tmp_path, scope="project") + hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8")) + hook = [h for h in hooks["hooks"]["Stop"] if "agent-learner" in h["command"]][0] + assert "--adapter codex" in hook["command"] + assert "--session-id $CODEX_SESSION_ID" in hook["command"] + assert "--cwd $CWD" in hook["command"] + assert "--model-id $CODEX_MODEL" in hook["command"] + + +def test_install_codex_hooks_user_scope(tmp_path: Path) -> None: + home = tmp_path / "home" + install_codex_hooks(home, scope="user") + hooks_path = home / ".codex" / "hooks.json" + assert hooks_path.exists() + hooks = json.loads(hooks_path.read_text(encoding="utf-8")) + assert any("agent-learner" in h["command"] for h in hooks["hooks"]["Stop"]) diff --git a/tests/test_hermit_adapter.py b/tests/test_hermit_adapter.py new file mode 100644 index 0000000..08f26e0 --- /dev/null +++ b/tests/test_hermit_adapter.py @@ -0,0 +1,86 @@ +"""Tests for the Hermit adapter (adapters/hermit.py).""" +from __future__ import annotations + +import json +from pathlib import Path + +from agent_learner.adapters.hermit import emit_session_event, install_hermit_hooks + + +def test_install_hooks_creates_settings_file(tmp_path: Path) -> None: + install_hermit_hooks(tmp_path, scope="project") + settings_path = tmp_path / ".hermit" / "settings.json" + assert settings_path.exists() + settings = json.loads(settings_path.read_text(encoding="utf-8")) + hooks = settings["hooks"]["OnStop"] + assert any("agent-learner process" in h["command"] for h in hooks) + + +def test_install_hooks_idempotent(tmp_path: Path) -> None: + install_hermit_hooks(tmp_path, scope="project") + install_hermit_hooks(tmp_path, scope="project") + settings = json.loads((tmp_path / ".hermit" / "settings.json").read_text(encoding="utf-8")) + al_hooks = [h for h in settings["hooks"]["OnStop"] if "agent-learner" in h["command"]] + assert len(al_hooks) == 1 + + +def test_install_hooks_preserves_existing_hooks(tmp_path: Path) -> None: + existing = {"hooks": {"OnStop": [{"command": "my-other-hook"}]}} + (tmp_path / ".hermit").mkdir() + (tmp_path / ".hermit" / "settings.json").write_text(json.dumps(existing)) + install_hermit_hooks(tmp_path, scope="project") + settings = json.loads((tmp_path / ".hermit" / "settings.json").read_text(encoding="utf-8")) + commands = [h["command"] for h in settings["hooks"]["OnStop"]] + assert "my-other-hook" in commands + assert any("agent-learner" in c for c in commands) + + +def test_install_hooks_user_scope(tmp_path: Path) -> None: + home = tmp_path / "home" + install_hermit_hooks(home, scope="user") + settings_path = home / ".hermit" / "settings.json" + assert settings_path.exists() + settings = json.loads(settings_path.read_text(encoding="utf-8")) + hooks = settings["hooks"]["OnStop"] + assert any("agent-learner process" in h["command"] for h in hooks) + + +def test_install_hooks_command_contains_hermit_adapter(tmp_path: Path) -> None: + install_hermit_hooks(tmp_path, scope="project") + settings = json.loads((tmp_path / ".hermit" / "settings.json").read_text(encoding="utf-8")) + hook = [h for h in settings["hooks"]["OnStop"] if "agent-learner" in h["command"]][0] + assert "--adapter hermit" in hook["command"] + assert "--session-id $HERMIT_SESSION_ID" in hook["command"] + assert "--cwd $CWD" in hook["command"] + assert "--model-id $HERMIT_MODEL_ID" in hook["command"] + + +def test_emit_session_event_creates_file(tmp_path: Path) -> None: + path = emit_session_event( + tmp_path, + session_id="test-123", + cwd=str(tmp_path), + model_id="glm-5.1", + outcome="success", + tool_call_count=10, + ) + assert path.exists() + event = json.loads(path.read_text(encoding="utf-8")) + assert event["adapter"] == "hermit" + assert event["event_name"] == "session_end" + assert event["session_id"] == "test-123" + assert event["payload"]["outcome"] == "success" + assert event["payload"]["tool_call_count"] == 10 + assert event["payload"]["model_id"] == "glm-5.1" + + +def test_emit_session_event_creates_events_dir(tmp_path: Path) -> None: + emit_session_event( + tmp_path, + session_id="s1", + cwd=str(tmp_path), + model_id="test-model", + outcome="failure", + tool_call_count=3, + ) + assert (tmp_path / ".agent-learner" / "events" / "hermit").exists()