Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
28 changes: 27 additions & 1 deletion src/agent_learner/adapters/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
28 changes: 27 additions & 1 deletion src/agent_learner/adapters/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
41 changes: 41 additions & 0 deletions src/agent_learner/adapters/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
67 changes: 67 additions & 0 deletions src/agent_learner/adapters/hermit.py
Original file line number Diff line number Diff line change
@@ -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)
37 changes: 29 additions & 8 deletions src/agent_learner/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions tests/test_bootstrap_cli.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading