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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format is inspired by Keep a Changelog and is intentionally lightweight whil
### Changed
- Removed the legacy `install-codex`, `install-claude`, and `install-hermes` CLI commands. `agent-learner bootstrap` is now the only install entrypoint, with `--adapters` and per-adapter scope flags handling selective setup.
- npm wrapper help, completion, and lane install forwarding now point to `bootstrap` instead of the removed top-level `install-*` aliases.
- Hermes user-scope bootstrap now auto-merges `pre_llm_call` and `on_session_end` hooks into an existing `~/.hermes/config.yaml`, writes a backup before updating the active config, and keeps `config.agent-learner.yaml` as a re-sync/reference snippet.

## [0.3.22] - 2026-04-25

Expand Down
4 changes: 2 additions & 2 deletions docs/adapter-convergence.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ After inspecting the current local environments:
- prompt-time context injection via the real Hermes `pre_llm_call` shell hook
- session-end capture via the real Hermes `on_session_end` shell hook and normalized `session_end` events
- shared retrieval and event-processing core reused instead of adding a Hermes-specific learner
- current recommended rollout is explicit opt-in and project scope first
- installer writes a project-local `config.yaml` only when missing and always emits a mergeable `config.agent-learner.yaml` snippet for fail-safe adoption
- current recommended rollout is explicit opt-in, with user-scope bootstrap now validated against live Hermes runtime and project scope still available for isolated opt-in verification
- installer writes `config.yaml` only when missing, auto-merges required hooks into an existing user-scope active config with backup, and still emits `config.agent-learner.yaml` as a re-sync snippet

## Principle

Expand Down
8 changes: 5 additions & 3 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,13 @@ The Hermes adapter wires two real Hermes shell hooks:
Safe activation model:
- user-scope install reuses the Hermes config and model/auth you already run
- `config.yaml` is only created automatically when missing
- `config.agent-learner.yaml` is always written as a mergeable snippet for existing Hermes setups
- if `~/.hermes/config.yaml` already exists, bootstrap now backs it up and automatically merges the `pre_llm_call` and `on_session_end` hook entries into the active config
- `~/.hermes/config.yaml.agent-learner.bak` stores the pre-merge backup when an existing config is updated
- `config.agent-learner.yaml` is still written as a re-sync/reference snippet for existing Hermes setups
- `AGENT_LEARNER_README.md` explains the generated files and merge path

If you already maintain your own Hermes config, review and merge the hook entries from
`~/.hermes/config.agent-learner.yaml` into the config you actually run.
This means the normal user-scope path is now one command: install with `bootstrap`, then run Hermes.
You only need to inspect `~/.hermes/config.agent-learner.yaml` if you want to review or re-sync the generated hook snippet manually.

If you explicitly want an isolated project-local Hermes home instead, that path is still available:

Expand Down
2 changes: 1 addition & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Notes:
- Hermes is still marked experimental.
- Default `bootstrap` now installs `codex,claude,hermes`.
- Hermes default install scope is `user`.
- The installer writes `~/.hermes/config.agent-learner.yaml` and `~/.hermes/AGENT_LEARNER_README.md` so existing Hermes users can merge hook entries safely.
- The installer writes `~/.hermes/config.agent-learner.yaml` and `~/.hermes/AGENT_LEARNER_README.md`, and now auto-merges the hook entries into an existing `~/.hermes/config.yaml` after creating a backup.
- `qa-hermes-smoke` now checks direct script output plus `hermes hooks list/doctor/test` runtime wiring.

## If you are validating a release
Expand Down
97 changes: 96 additions & 1 deletion src/agent_learner/adapters/hermes.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ def main() -> int:
]

CONFIG_SNIPPET_HEADER = "# agent-learner hermes hooks snippet\n"
CONFIG_BACKUP_NAME = "config.yaml.agent-learner.bak"
ACTIVATION_NOTES = """# Agent Learner + Hermes

This directory contains a project-local Hermes home for agent-learner hooks.
Expand Down Expand Up @@ -278,6 +279,10 @@ def _command_for_script(script_path: Path, *, scope: str) -> str:


def _render_config_yaml(*, prompt_command: str, auto_command: str) -> str:
return _render_hooks_block(prompt_command=prompt_command, auto_command=auto_command) + "hooks_auto_accept: false\n"


def _render_hooks_block(*, prompt_command: str, auto_command: str) -> str:
return (
"hooks:\n"
" pre_llm_call:\n"
Expand All @@ -286,10 +291,96 @@ def _render_config_yaml(*, prompt_command: str, auto_command: str) -> str:
" on_session_end:\n"
f" - command: {auto_command!r}\n"
" timeout: 15\n"
"hooks_auto_accept: false\n"
)


def _merge_hooks_section(existing_text: str, *, prompt_command: str, auto_command: str) -> str:
lines = existing_text.splitlines()
desired = {
"pre_llm_call": [f" - command: {prompt_command!r}", " timeout: 15"],
"on_session_end": [f" - command: {auto_command!r}", " timeout: 15"],
}
if not lines:
return _render_hooks_block(prompt_command=prompt_command, auto_command=auto_command).rstrip("\n")
if len(lines) == 1 and lines[0].strip() == "hooks: {}":
return _render_hooks_block(prompt_command=prompt_command, auto_command=auto_command).rstrip("\n")

merged: list[str] = ["hooks:"]
index = 1
seen_events: set[str] = set()
while index < len(lines):
line = lines[index]
stripped = line.strip()
if line.startswith(" ") and not line.startswith(" ") and stripped.endswith(":"):
event = stripped[:-1]
start = index
index += 1
while index < len(lines):
next_line = lines[index]
next_stripped = next_line.strip()
if next_line.startswith(" ") and not next_line.startswith(" ") and next_stripped.endswith(":"):
break
index += 1
block = lines[start:index]
block_text = "\n".join(block)
addition = desired.get(event)
if addition and addition[0] not in block_text:
block = block + addition
merged.extend(block)
seen_events.add(event)
continue
index += 1

for event in ("pre_llm_call", "on_session_end"):
if event in seen_events:
continue
merged.append(f" {event}:")
merged.extend(desired[event])
return "\n".join(merged)


def _merge_user_config(config_path: Path, *, prompt_command: str, auto_command: str) -> Path | None:
original = config_path.read_text(encoding="utf-8")
backup_path = config_path.with_name(CONFIG_BACKUP_NAME)
lines = original.splitlines()
top_level_indexes: list[tuple[str, int]] = []
for idx, line in enumerate(lines):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if not line.startswith((" ", "\t")) and ":" in stripped:
top_level_indexes.append((stripped.split(":", 1)[0], idx))

sections: dict[str, tuple[int, int]] = {}
for offset, (key, start) in enumerate(top_level_indexes):
end = top_level_indexes[offset + 1][1] if offset + 1 < len(top_level_indexes) else len(lines)
sections[key] = (start, end)

existing_hooks_text = ""
if "hooks" in sections:
start, end = sections["hooks"]
existing_hooks_text = "\n".join(lines[start:end])
hooks_block = _merge_hooks_section(existing_hooks_text, prompt_command=prompt_command, auto_command=auto_command)

if "hooks" in sections:
start, end = sections["hooks"]
new_lines = lines[:start] + hooks_block.splitlines() + lines[end:]
else:
new_lines = lines + ([""] if lines and lines[-1].strip() else []) + hooks_block.splitlines()

if not any(line.startswith("hooks_auto_accept:") for line in new_lines):
if new_lines and new_lines[-1].strip():
new_lines.append("")
new_lines.append("hooks_auto_accept: false")

merged_text = "\n".join(new_lines).rstrip("\n") + "\n"
if merged_text == original:
return backup_path if backup_path.exists() else None
backup_path.write_text(original, encoding="utf-8")
config_path.write_text(merged_text, encoding="utf-8")
return backup_path


def _write_config_files(hermes_root: Path, *, scope: str, prompt_script: Path, auto_script: Path) -> list[Path]:
prompt_command = _command_for_script(prompt_script, scope=scope)
auto_command = _command_for_script(auto_script, scope=scope)
Expand All @@ -300,6 +391,10 @@ def _write_config_files(hermes_root: Path, *, scope: str, prompt_script: Path, a

if not config_path.exists():
written.append(write_text(config_path, config_text))
elif scope == "user":
backup_path = _merge_user_config(config_path, prompt_command=prompt_command, auto_command=auto_command)
if backup_path is not None:
written.append(backup_path)
written.append(write_text(snippet_path, CONFIG_SNIPPET_HEADER + config_text))
written.append(write_text(hermes_root / "AGENT_LEARNER_README.md", ACTIVATION_NOTES))
return written
Expand Down
7 changes: 6 additions & 1 deletion src/agent_learner/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,13 @@ def emit_hermes_install_guidance(target: Path, *, scope: str, had_config: bool)

print("[agent-learner] Hermes user-scope hooks installed.", file=sys.stderr)
if had_config:
backup_path = hermes_root / "config.yaml.agent-learner.bak"
print(
f"[agent-learner] Existing Hermes config preserved; review and merge {snippet_path} into your active Hermes config.",
f"[agent-learner] Existing Hermes config preserved and agent-learner hooks merged into your active Hermes config. Backup: {backup_path}",
file=sys.stderr,
)
print(
f"[agent-learner] Review snippet for future re-syncs: {snippet_path}",
file=sys.stderr,
)
else:
Expand Down
13 changes: 9 additions & 4 deletions tests/test_cli_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,24 @@ def test_bootstrap_hermes_only_defaults_to_user_scope(monkeypatch, tmp_path: Pat
assert "project-local opt-in" not in stderr


def test_bootstrap_hermes_preserves_existing_config_and_prints_merge_guidance(monkeypatch, tmp_path: Path, capsys) -> None:
def test_bootstrap_hermes_preserves_existing_config_and_auto_merges_hooks(monkeypatch, tmp_path: Path, capsys) -> None:
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
config_path = hermes_root / "config.yaml"
config_path.write_text("model:\n provider: openai-codex\n", encoding="utf-8")
config_path.write_text("model:\n provider: openai-codex\nhooks: {}\n", encoding="utf-8")
monkeypatch.setattr(
"sys.argv",
["agent-learner", "bootstrap", "--target", str(tmp_path), "--adapters", "hermes"],
)
assert cli_main() == 0
assert config_path.read_text(encoding="utf-8") == "model:\n provider: openai-codex\n"
config_text = config_path.read_text(encoding="utf-8")
assert "provider: openai-codex" in config_text
assert "pre_llm_call" in config_text
assert "on_session_end" in config_text
assert (hermes_root / "config.yaml.agent-learner.bak").exists()
stderr = capsys.readouterr().err
assert "Existing Hermes config preserved" in stderr
assert "Hermes user-scope hooks installed" in stderr
assert "merged into your active Hermes config" in stderr
assert "config.agent-learner.yaml" in stderr


Expand Down
31 changes: 31 additions & 0 deletions tests/test_installers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from agent_learner.adapters import install_claude_adapter, install_codex_adapter, install_hermes_adapter
from agent_learner.adapters.claude import install_claude_adapter_with_scope as install_claude_adapter_with_scope
from agent_learner.adapters.codex import install_codex_adapter_with_scope
from agent_learner.adapters.hermes import install_hermes_adapter_with_scope
from agent_learner.core.storage import read_project_registry, register_project, resolve_learning_root, should_register_project, storage_migration_marker_path


Expand Down Expand Up @@ -108,6 +109,36 @@ def test_install_hermes_adapter_creates_expected_assets(tmp_path: Path) -> None:
assert '--adapter", "hermes"' not in prompt_script


def test_install_hermes_user_scope_merges_hooks_into_existing_config_without_duplicates(tmp_path: Path) -> None:
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
config_path = hermes_root / "config.yaml"
config_path.write_text(
"model:\n"
" provider: openai-codex\n"
"hooks:\n"
" pre_llm_call:\n"
" - command: 'python existing-hook.py'\n"
" timeout: 5\n"
"hooks_auto_accept: false\n",
encoding="utf-8",
)

first_written = install_hermes_adapter_with_scope(tmp_path, scope="user")
second_written = install_hermes_adapter_with_scope(tmp_path, scope="user")

config_text = config_path.read_text(encoding="utf-8")
assert "provider: openai-codex" in config_text
assert "python existing-hook.py" in config_text
assert config_text.count("hermes_prompt_context.py") == 1
assert config_text.count("auto_session_learning.py") == 1
assert "hooks_auto_accept: false" in config_text
assert (hermes_root / "config.agent-learner.yaml").exists()
assert (hermes_root / "config.yaml.agent-learner.bak").exists()
assert first_written
assert second_written


def test_installers_are_independent(tmp_path: Path) -> None:
install_codex_adapter(tmp_path)
assert not (tmp_path / ".claude").exists()
Expand Down
Loading