From 1dedca0e559bcd84cc67cd479c98b42aa2e51332 Mon Sep 17 00:00:00 2001 From: cafitac Date: Mon, 27 Apr 2026 23:14:44 +0900 Subject: [PATCH 1/2] feat: make bootstrap the only install entrypoint remove legacy install-codex/install-claude/install-hermes usage from the active UX point wrapper help/completion and lane install forwarding to bootstrap add explicit migration guidance for removed install commands update README, install docs, and release smoke docs to bootstrap-only flows align Hermes .dev design and plan docs with bootstrap-only installation --- .dev/design/hermes-adapter-implementation.md | 470 ++++++++++++++ .../hermes-adapter-implementation-plan.md | 573 ++++++++++++++++++ .dev/prd/hermes-adapter.md | 415 +++++++++++++ CHANGELOG.md | 4 + README.md | 20 +- docs/install.md | 107 +++- docs/prerelease-checklist.md | 1 + docs/publish-smoke-checklist.md | 2 + docs/quickstart.md | 32 +- docs/release-process.md | 1 + lib/wrapper.cjs | 70 +-- src/agent_learner/cli/main.py | 329 +++++++++- test/wrapper.test.cjs | 50 +- tests/test_cli_bootstrap.py | 257 +++++++- 14 files changed, 2212 insertions(+), 119 deletions(-) create mode 100644 .dev/design/hermes-adapter-implementation.md create mode 100644 .dev/plans/hermes-adapter-implementation-plan.md create mode 100644 .dev/prd/hermes-adapter.md diff --git a/.dev/design/hermes-adapter-implementation.md b/.dev/design/hermes-adapter-implementation.md new file mode 100644 index 0000000..ed88e91 --- /dev/null +++ b/.dev/design/hermes-adapter-implementation.md @@ -0,0 +1,470 @@ +# Design: Hermes Adapter Implementation for agent-learner + +**작성일**: 2026-04-27 15:38 KST +**상태**: 구현 반영됨 (MVP, experimental) +**관련 PRD**: `.dev/prd/hermes-adapter.md` + +--- + +## 1. 목적 + +이 문서는 `agent-learner`에 Hermes adapter를 추가하기 위한 구현 설계를 정리한다. + +PRD가 "왜 Hermes adapter가 필요한가"를 정의했다면, 이 문서는 다음을 정의한다. +- 어떤 파일을 추가/수정할지 +- 어떤 CLI surface를 열지 +- 어떤 이벤트 스키마를 사용할지 +- Hermes 런타임과 어느 지점에서 연결할지 +- MVP를 어떤 순서로 구현할지 + +--- + +## 2. 현재 기준점 + +이미 존재하는 adapter 관련 구현: +- `src/agent_learner/adapters/common.py` +- `src/agent_learner/adapters/codex.py` +- `src/agent_learner/adapters/claude.py` +- `src/agent_learner/adapters/codex_context.py` +- `src/agent_learner/cli/main.py` + +현재 CLI surface: +- `bootstrap` +- `bootstrap --adapters codex,claude` +- `capture-event --adapter {codex,claude}` +- `process-events --adapter {codex,claude}` +- `review-candidates --adapter {codex,claude}` + +즉 Hermes adapter MVP의 최소 구현은 크게 두 층이다. + +1. adapter registration 확장 +- CLI choice에 `hermes` 추가 +- installer/export 추가 + +2. Hermes runtime glue 추가 +- session end event capture +- prompt-time retrieval injection +- 설치용 파일/설정 생성 + +--- + +## 3. 구현 원칙 + +### 3.1 기존 adapter와 수렴 +Hermes adapter는 새로운 패턴을 만들기보다 Codex/Claude adapter가 이미 보여준 구조를 따른다. + +- installer는 `adapters/hermes.py` +- 가능하면 `common.py` 유틸 재사용 +- event capture는 shared CLI 재사용 +- retrieval/ranking은 core 재사용 +- Hermes 고유 로직은 hook boundary에만 남김 + +### 3.2 Project scope 우선 +MVP는 `scope=project`를 우선한다. + +이유: +- 자동 학습 자산은 project-local rule로 시작하는 편이 안전함 +- Hermes memory/skill과 역할 충돌을 줄일 수 있음 +- user scope는 나중에 fanout/global sync 전략과 함께 추가하는 편이 낫다 + +명시적 결정: +- Hermes install entrypoint는 `bootstrap --adapters hermes`로 둔다. +- bootstrap 기본 adapter 목록은 초기에는 유지하고, Hermes는 opt-in experimental adapter로 시작한다. +- user scope는 `--hermes-scope user`로 제공하되, MVP 검증 범위에서는 제외 가능하다. + +### 3.3 Prompt bloat 방지 +Hermes는 이미 system prompt / skills / memories / toolset metadata가 큰 편일 수 있다. +따라서 adapter는 retrieval-first + small payload를 강제해야 한다. + +--- + +## 4. 제안 파일 변경 + +### 4.1 신규 파일 + +현재 구현된 파일: + +1. `src/agent_learner/adapters/hermes.py` +- installer +- Hermes auto session learning helper script 템플릿 +- Hermes context hook helper script 템플릿 +- `.hermes/settings.json` 병합 + +추가 후보(이번 MVP에서는 미도입): + +2. `src/agent_learner/adapters/hermes_context.py` +- 필요 시 retrieval 결과를 Hermes 전용 포맷으로 분리 +- 현재는 `codex_context.py`의 shared retrieval/render 함수를 재사용 + +3. `plugins/hermes/README.md` +- Hermes 사용자용 설치/작동 방식 설명 +- 이번 MVP에서는 아직 미추가 + +### 4.2 수정 파일 + +1. `src/agent_learner/adapters/__init__.py` +- `install_hermes_adapter` export 추가 + +2. `src/agent_learner/cli/main.py` +- `bootstrap --adapters` help에 `hermes` 추가 +- `capture-event/process-events/review-candidates/history` adapter choices에 `hermes` 추가 +- 필요 시 `render-hermes-context` command 추가 +- Hermes adapter를 experimental/opt-in으로 표시할 노출 방식 검토 + +3. `docs/adapter-convergence.md` +- 승인 후 Hermes를 official convergence target으로 문서화 + +4. `docs/install.md`, `docs/quickstart.md` +- 승인 후 Hermes 설치/예시 반영 + +--- + +## 5. Hermes adapter 책임 + +### 5.1 installer +예상 public function: +```python +def install_hermes_adapter_with_scope(target_root: Path, *, scope: str = "project") -> list[Path]: ... + +def install_hermes_adapter(target_root: Path) -> list[Path]: ... +``` + +역할: +- `.hermes/` 또는 Hermes 관련 설정 파일 생성/병합 +- `.agent-learner/events/hermes/` 보장 +- helper script 설치 +- project scope일 때 `.gitignore` 갱신 +- idempotent 보장 + +제한: +- installer는 Hermes memory/skills/session 로그 본문을 자동 수정하지 않는다. +- installer는 adapter glue와 최소 문서/스크립트만 다룬다. +- unrelated project files를 건드리지 않는 것을 기본 원칙으로 한다. + +### 5.2 auto session learning script +Codex/Claude처럼 adapter 내부 상수 문자열로 helper script를 생성한다. + +예상 역할: +- stdin JSON payload 읽기 +- `cwd`, `session_id`, `transcript_path`, `model`, `summary` 추출 +- project root 탐지 +- `capture-event --adapter hermes --event-name session_end` +- 이어서 `process-events --adapter hermes --limit 1` + +### 5.3 prompt context script +새 요청 직전 relevant rule을 조회한다. + +예상 역할: +- prompt/user message 추출 +- project root 탐지 +- `render-hermes-context` 또는 shared retrieval command 호출 +- Hermes가 이해할 수 있는 injection payload stdout 출력 + +--- + +## 6. CLI 변경안 + +### 6.1 install command +공식 경로: +```bash +agent-learner bootstrap --adapters hermes --target --hermes-scope project|user +``` + +기존 `install-hermes` 같은 전용 명령 대신 bootstrap-only path를 사용한다. + +### 6.2 bootstrap command +현재: +```bash +agent-learner bootstrap --adapters codex,claude +``` + +변경: +```bash +agent-learner bootstrap --adapters codex,claude,hermes +``` + +주의: +- 기본값을 바로 `codex,claude,hermes`로 바꿀지는 별도 결정 +- 초기에는 help text만 확장하고 default는 유지하는 편이 안전할 수 있음 + +### 6.3 adapter choice 확장 +현재 `choices=["codex", "claude"]`인 곳을 `choices=["codex", "claude", "hermes"]`로 확장한다. + +최소 대상: +- `capture-event` +- `process-events` +- `review-candidates` +- `history` +- `history-summary` + +검토 대상: +- dashboard filters +- future `process`/`doctor`/`qa` surfaces + +### 6.4 retrieval render command +현재 MVP 구현: +```bash +agent-learner render-hermes-context --project-root . --prompt "..." --format text|json|hook-json +``` + +비고: +- 구현은 `codex_context.py`의 shared retrieval/render 함수를 재사용한다. +- `hook-json`은 Hermes prompt hook helper script가 바로 stdout pass-through 할 수 있도록 지원한다. +- 장기적으로는 `render-context --adapter hermes` 형태로 수렴 가능하다. + +--- + +## 7. 이벤트 모델 + +Hermes도 core의 normalized event contract를 따라야 한다. + +예상 최소 event envelope: +```json +{ + "adapter": "hermes", + "event_name": "session_end", + "cwd": "/abs/path", + "session_id": "...", + "model": "openai-codex/gpt-5.4", + "transcript_path": "/abs/path/or/null", + "timestamp": "2026-04-27T15:38:00+09:00", + "payload": { + "user_summary": "...", + "assistant_summary": "...", + "tool_names": ["read_file", "search_files"], + "verification": "passed|failed|unknown", + "success": true + } +} +``` + +### 필수 조건 +- transcript path가 없더라도 event는 기록 가능해야 함 +- payload 누락 필드는 optional이어야 함 +- adapter-specific raw fields를 남기더라도 top-level normalized keys는 유지 + +--- + +## 8. Hermes 연동 지점 + +이 부분은 `agent-learner` 단독으로 확정할 수 없고 Hermes 쪽 runtime 구조를 확인해야 한다. 다만 현재 설계상 필요한 연결점은 명확하다. + +명시적 MVP 결정: +- 학습 이벤트는 우선 `session_end` 하나만 공식 지원한다. +- retrieval 주입은 pre-prompt 1지점만 공식 지원한다. +- 둘 다 준비되지 않으면 manual CLI 흐름으로 검증 가능한 상태를 먼저 만든다. + +### 8.1 session end hook +가장 중요한 지점. + +필요 정보: +- session id +- cwd +- model/provider +- final summary 또는 transcript path +- success/failure signal + +이 시점에서: +- `capture-event --adapter hermes --event-name session_end` +- `process-events --adapter hermes --limit 1` +호출 + +### 8.2 pre-prompt hook +새 user prompt 처리 직전에 retrieval을 수행. + +필요 정보: +- prompt text +- cwd/project root +- optional task metadata + +이 시점에서: +- approved rule top-N 조회 +- Hermes prompt structure에 맞는 injection payload 생성 + +### 8.3 explicit/manual sync fallback +hook 통합이 바로 어렵다면 MVP 단계에서는 수동 명령도 지원 가능하다. + +예: +```bash +agent-learner capture-event --adapter hermes --event-name session_end --project-root . --session-id manual-test +agent-learner process-events --adapter hermes --limit 1 +``` + +이 fallback은 테스트 및 bring-up에 유용하다. + +--- + +## 9. 저장 경로 + +MVP 기준 project-local root: +```text +/.agent-learner/ + events/hermes/ + candidates/ + learning/ + history/ + index/ + state/ +``` + +Hermes 자체 설정/도우미 파일은 scope별로 다를 수 있다. + +예상 후보: +- project scope: `/.hermes/...` +- user scope: `~/.hermes/...` + +주의: +- 현재 Hermes가 실제로 어떤 설정 구조를 강하게 기대하는지 검증 전까지 경로를 너무 일찍 고정하지 않는다. +- adapter 설계는 "Hermes runtime 설정에 삽입되는 helper" 수준으로 유지하고, core storage는 `.agent-learner`에 집중한다. + +--- + +## 10. Hermes context format 제안 + +Hermes는 Codex와 동일한 hook payload 형식을 쓸 필요는 없다. +중요한 것은 "작고 관련성 높은 learned context를 안정적으로 넣을 수 있느냐"다. + +MVP 출력 예시: +```json +{ + "learning_context": { + "rules": [ + { + "name": "repo-local-test-order", + "summary": "Run focused tests before full suite in this repo.", + "why": "Validated on recent sessions.", + "source": ".agent-learner/learning/approved/repo-local-test-order.md" + } + ] + } +} +``` + +또는 plain text block: +```text +[Learned project guidance] +- Run focused tests before full suite in this repo. +``` + +선호: +- Hermes가 structured preamble 삽입을 쉽게 지원하면 JSON/structured block +- 아니면 text block + +--- + +## 11. 구현 단계 + +### Phase 1 — CLI + adapter skeleton +수정: +- `adapters/hermes.py` 추가 +- `adapters/__init__.py` export 추가 +- `cli/main.py`에 Hermes bootstrap path 및 adapter choice 확장 + +검증: +- `agent-learner bootstrap --adapters hermes --target . --hermes-scope project` 동작 +- `.agent-learner/events/hermes/` 생성 + +### Phase 2 — manual event flow +수정: +- Hermes auto session helper script 추가 +- `capture-event/process-events` with `adapter=hermes` 동작 확인 + +검증: +- 수동 payload로 event 생성 +- candidate 생성 및 processed marker 생성 + +### Phase 3 — retrieval injection +수정: +- `hermes_context.py` 추가 +- `render-hermes-context` 또는 공통 render path 추가 + +검증: +- approved rule이 있을 때만 context 출력 +- include-needs-review 기본 false 유지 +- token budget 또는 top-N 제한 적용 + +### Phase 4 — runtime integration +수정: +- Hermes 런타임에 session-end / pre-prompt hook 연결 + +검증: +- 실제 Hermes 세션 2회 이상 반복 시 relevant learning이 자동 재주입됨 + +--- + +## 12. 테스트 전략 + +### 12.1 단위 테스트 +대상: +- installer idempotency +- event payload normalization +- prompt context formatter +- project root detection fallback +- adapter choice 확장 시 기존 codex/claude parser 회귀 없음 + +### 12.2 통합 테스트 +대상: +- bootstrap --adapters hermes 실행 후 파일 생성 검증 +- manual capture/process flow +- approved learning retrieval path +- bootstrap에서 Hermes opt-in 경로 검증 + +### 12.3 smoke test +대상: +- 예시 프로젝트에서 Hermes 세션 종료 이벤트 1회 생성 +- 다음 prompt에서 learned context 회수 확인 +- Codex/Claude smoke가 여전히 green인지 회귀 확인 + +주의: +- 실제 Hermes runtime 의존 smoke는 optional integration suite로 분리 가능 +- 초기에 Hermes smoke가 불안정하면 manual CLI smoke를 release gate로 사용한다. + +--- + +## 13. 리스크와 설계 선택 + +### 리스크 1: Hermes prompt surface 불명확 +대응: +- 먼저 manual render command 제공 +- runtime hook은 후속 단계로 연결 + +### 리스크 2: session transcript 품질 불균일 +대응: +- transcript path가 없을 때도 summary-only event 허용 +- extraction pipeline이 sparse payload에서도 동작하도록 유지 + +### 리스크 3: memory/skill과 learned rules 중복 +대응: +- PRD에 정의한 역할 구분을 installer README와 plugin README에도 반복 명시 + +### 리스크 4: adapter proliferation +대응: +- Hermes 전용 명령을 만들더라도 추후 공통 `render-context --adapter X`로 수렴 가능하게 구현 + +--- + +## 14. 오픈 질문 + +1. Hermes runtime에서 공식적으로 지원하는 session-end hook 지점이 있는가? +2. Hermes pre-prompt 단계에 structured JSON을 넣는 것이 가능한가? +3. Hermes가 session transcript path를 안정적으로 제공하는가? +4. user scope 설치 시 어떤 설정 파일을 patch해야 하는가? +5. installer가 Hermes skill/examples까지 배치해야 하는가, 아니면 pure adapter glue만 넣는 게 맞는가? + +--- + +## 15. 추천 구현 순서 + +1. `cli/main.py` adapter choice 확장 +2. `adapters/hermes.py` skeleton 추가 +3. `bootstrap --adapters hermes` 구현 +4. manual event flow 검증 +5. `hermes_context.py` + render command 추가 +6. Hermes runtime hook 연결 +7. docs 승격 준비 + +--- + +## 16. 한 줄 결론 + +Hermes adapter 구현은 새로운 learner를 만드는 작업이 아니라, 기존 `agent-learner` core에 Hermes runtime을 얇게 접속시키는 adapter + hook + formatter 작업으로 범위를 엄격히 제한해야 한다. diff --git a/.dev/plans/hermes-adapter-implementation-plan.md b/.dev/plans/hermes-adapter-implementation-plan.md new file mode 100644 index 0000000..e60e9bf --- /dev/null +++ b/.dev/plans/hermes-adapter-implementation-plan.md @@ -0,0 +1,573 @@ +# Hermes Adapter Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Add an experimental Hermes adapter to `agent-learner` that supports project-scoped install, normalized `session_end` event capture, manual processing, and compact prompt-time retrieval without regressing existing Codex/Claude flows. + +**Architecture:** Keep `agent-learner` core as the canonical learning plane and add Hermes as a thin adapter. The implementation should first extend CLI and installer surfaces, then add Hermes-specific helper scripts and retrieval formatting, and only after that wire Hermes runtime hook integration. Preserve project-local `.agent-learner/` as the system of record and avoid mutating unrelated Hermes memory/skills files. + +**Tech Stack:** Python 3, argparse CLI in `src/agent_learner/cli/main.py`, adapter modules in `src/agent_learner/adapters/`, pytest test suite under `tests/`. + +--- + +## Pre-read + +Read these files before starting: +- `.dev/prd/hermes-adapter.md` +- `.dev/design/hermes-adapter-implementation.md` +- `.dev/reviews/hermes-adapter-review-notes.md` +- `src/agent_learner/cli/main.py` +- `src/agent_learner/adapters/codex.py` +- `src/agent_learner/adapters/claude.py` +- `tests/test_installers.py` +- `tests/test_cli_bootstrap.py` +- `tests/test_retrieval_adapter_filter.py` + +Implementation constraints: +- Hermes adapter is experimental/opt-in. +- MVP default recommendation is project scope. +- MVP event is `session_end` only. +- MVP retrieval command is `render-hermes-context`. +- Do not mutate Hermes memory/skills/session content. +- Do not regress Codex/Claude installers or smoke paths. + +--- + +## Task 1: Add failing installer tests for Hermes adapter + +**Objective:** Define the expected Hermes installer behavior in tests before implementing the adapter. + +**Files:** +- Modify: `tests/test_installers.py` +- Modify: `tests/test_cli_bootstrap.py` + +**Step 1: Write failing test for project-scope installer assets** + +Add a test in `tests/test_installers.py` that expects: +- `install_hermes_adapter(tmp_path)` to create: + - `tmp_path / ".agent-learner" / "events" / "hermes"` + - Hermes adapter helper script path(s) + - Hermes adapter config/hook path(s) under project-local Hermes area +- installer returns a non-empty written-path list +- installer does not create unrelated Codex/Claude roots + +Expected assertions should also check that the generated helper script contains `capture-event --adapter hermes --event-name session_end` and `process-events --adapter hermes --limit 1`. + +**Step 2: Run the installer test to verify failure** + +Run: +```bash +pytest tests/test_installers.py -k hermes -v +``` +Expected: FAIL — missing import/function/module for Hermes adapter. + +**Step 3: Write failing CLI/bootstrap tests** + +Add tests in `tests/test_cli_bootstrap.py` for: +- removed-command guidance for `agent-learner install-hermes` +- `agent-learner bootstrap --target --adapters hermes --hermes-scope project` +- optional explicit opt-in help-path behavior if you expose Hermes in bootstrap help without changing defaults + +Each test should assert Hermes assets exist and Codex/Claude are not created unless explicitly requested. + +**Step 4: Run CLI/bootstrap tests to verify failure** + +Run: +```bash +pytest tests/test_cli_bootstrap.py -k hermes -v +``` +Expected: FAIL — unknown command or unsupported adapter. + +**Step 5: Commit** + +```bash +git add tests/test_installers.py tests/test_cli_bootstrap.py +git commit -m "test: add failing Hermes adapter installer and bootstrap tests" +``` + +--- + +## Task 2: Implement Hermes adapter module and exports + +**Objective:** Add the Hermes adapter module with a project-scoped installer and export it through the adapter package. + +**Files:** +- Create: `src/agent_learner/adapters/hermes.py` +- Modify: `src/agent_learner/adapters/__init__.py` +- Test: `tests/test_installers.py` + +**Step 1: Write minimal adapter module skeleton** + +Create `src/agent_learner/adapters/hermes.py` with: +- `install_hermes_adapter_with_scope(target_root: Path, *, scope: str = "project") -> list[Path]` +- `install_hermes_adapter(target_root: Path) -> list[Path]` +- helper-script string constants similar in shape to Codex/Claude adapters +- use `common.py` helpers where possible (`ensure_dir`, `merge_json_file`, `write_text`, `append_lines_if_missing`, or `upsert_hook` if applicable) + +Minimal behavior for MVP: +- support `scope in {"project", "user"}` at the function level +- for `project` scope, create `.agent-learner/events/hermes/` +- write project-local Hermes adapter helper files +- keep installer idempotent +- do not touch unrelated files + +**Step 2: Export installer from adapter package** + +Update `src/agent_learner/adapters/__init__.py` to export `install_hermes_adapter` (and scope variant if existing pattern uses it internally). + +**Step 3: Run targeted tests** + +Run: +```bash +pytest tests/test_installers.py -k hermes -v +``` +Expected: PASS for new Hermes installer tests, or narrow failures showing missing CLI wiring only. + +**Step 4: Run existing installer regression tests** + +Run: +```bash +pytest tests/test_installers.py -v +``` +Expected: PASS — Codex/Claude installer tests remain green. + +**Step 5: Commit** + +```bash +git add src/agent_learner/adapters/hermes.py src/agent_learner/adapters/__init__.py tests/test_installers.py +git commit -m "feat: add Hermes adapter installer skeleton" +``` + +--- + +## Task 3: Extend CLI to recognize Hermes adapter + +**Objective:** Make CLI surfaces understand Hermes as an experimental adapter. + +**Files:** +- Modify: `src/agent_learner/cli/main.py` +- Test: `tests/test_cli_bootstrap.py` + +**Step 1: Keep bootstrap-only CLI path** + +Update CLI parser/setup so Hermes is reachable through bootstrap flags rather than a dedicated install subcommand: +```text +bootstrap --adapters hermes --target --hermes-scope +``` + +Follow the bootstrap adapter pattern and keep Hermes on the same install surface as the other adapters. + +**Step 2: Extend adapter choices** + +Update these command parsers to include `hermes` in choices where applicable: +- `capture-event` +- `process-events` +- `review-candidates` +- `history` +- `history-summary` + +Also update bootstrap adapter help text to mention `hermes` as opt-in/experimental if you surface it there. + +**Step 3: Add command execution branch** + +Wire the Hermes bootstrap execution path in `cli_main()` to call the Hermes installer and print written paths, following the current bootstrap convention. + +**Step 4: Run CLI tests** + +Run: +```bash +pytest tests/test_cli_bootstrap.py -k "hermes or bootstrap" -v +``` +Expected: PASS for Hermes tests and no regressions in bootstrap behavior. + +**Step 5: Run broader CLI regression slice** + +Run: +```bash +pytest tests/test_cli_bootstrap.py -v +``` +Expected: PASS — existing Codex/Claude bootstrap and render-codex-context tests remain green. + +**Step 6: Commit** + +```bash +git add src/agent_learner/cli/main.py tests/test_cli_bootstrap.py +git commit -m "feat: add Hermes CLI install and adapter registration" +``` + +--- + +## Task 4: Add failing tests for Hermes event capture and processing + +**Objective:** Lock down the normalized Hermes event path before implementation. + +**Files:** +- Modify: `tests/test_cli_bootstrap.py` +- Test: `src/agent_learner/cli/main.py` + +**Step 1: Add failing capture-event test** + +Add a test analogous to `test_capture_event_command_writes_normalized_event` but with: +- `--adapter hermes` +- `--event-name session_end` +- stdin JSON payload containing a small summary-only event + +Assert: +- output path exists +- payload JSON has `adapter == "hermes"` +- payload JSON has `event_name == "session_end"` +- summary payload survives serialization + +**Step 2: Add failing process-events test** + +Add a test analogous to the Claude event processing test but for Hermes: +- create `.agent-learner/events/hermes/session_end-s1.json` +- give it a minimal transcript path or summary-only payload depending on pipeline requirements +- call `process-events --adapter hermes --format json` + +Assert: +- command exits 0 +- output is JSON list +- result contains a promotion/review status, or at minimum does not reject Hermes as an unsupported adapter + +**Step 3: Run tests to verify failure** + +Run: +```bash +pytest tests/test_cli_bootstrap.py -k "capture_event or process_events" -v +``` +Expected: FAIL — unsupported Hermes adapter or missing implementation details. + +**Step 4: Commit** + +```bash +git add tests/test_cli_bootstrap.py +git commit -m "test: add failing Hermes event capture and process tests" +``` + +--- + +## Task 5: Make manual Hermes event flow pass + +**Objective:** Support Hermes in normalized event capture and shared processing without runtime hook integration yet. + +**Files:** +- Modify: `src/agent_learner/cli/main.py` +- Modify: `src/agent_learner/core/events.py` (if needed) +- Modify: `src/agent_learner/core/pipeline.py` or related processing modules only if required +- Test: `tests/test_cli_bootstrap.py` + +**Step 1: Implement minimal Hermes acceptance in capture-event path** + +Update the CLI / event-writing path so `adapter=hermes` is accepted and written under: +```text +.agent-learner/events/hermes/ +``` + +**Step 2: Ensure process-events can consume Hermes event files** + +If processing currently assumes only Codex/Claude event names or paths, generalize the minimum necessary logic so Hermes `session_end` events work without introducing adapter-specific core branching unless truly needed. + +**Step 3: Run targeted tests** + +Run: +```bash +pytest tests/test_cli_bootstrap.py -k "hermes and (capture or process)" -v +``` +Expected: PASS. + +**Step 4: Run nearby regressions** + +Run: +```bash +pytest tests/test_cli_bootstrap.py -k "capture_event or process_events or review_candidates" -v +``` +Expected: PASS — existing Codex/Claude event flow remains green. + +**Step 5: Commit** + +```bash +git add src/agent_learner/cli/main.py src/agent_learner/core/events.py src/agent_learner/core/pipeline.py tests/test_cli_bootstrap.py +git commit -m "feat: support Hermes normalized event capture and processing" +``` + +--- + +## Task 6: Add failing retrieval-context tests for Hermes + +**Objective:** Define the Hermes prompt-time retrieval contract before implementing it. + +**Files:** +- Modify: `tests/test_cli_bootstrap.py` +- Modify: `tests/test_retrieval_adapter_filter.py` + +**Step 1: Add failing adapter-filter test for Hermes** + +Extend adapter-filter coverage so Hermes behaves like other adapters: +- universal rules included for Hermes +- Hermes-specific rules included when request adapter is Hermes +- non-Hermes rules excluded when adapter is Hermes + +If the current harness field is used, add a test using `harness="hermes"`. + +**Step 2: Add failing render command test** + +Add a CLI test similar to `test_render_codex_context_command_outputs_hook_json` but for: +```text +render-hermes-context +``` + +Assert: +- command exits 0 +- payload is structured and bounded +- output includes learned guidance from an approved rule +- output shape is appropriate for Hermes hook consumption + +**Step 3: Run tests to verify failure** + +Run: +```bash +pytest tests/test_retrieval_adapter_filter.py tests/test_cli_bootstrap.py -k hermes -v +``` +Expected: FAIL — missing Hermes retrieval command/format path. + +**Step 4: Commit** + +```bash +git add tests/test_retrieval_adapter_filter.py tests/test_cli_bootstrap.py +git commit -m "test: add failing Hermes retrieval and render-context tests" +``` + +--- + +## Task 7: Implement Hermes retrieval formatter and command + +**Objective:** Add compact prompt-time retrieval for Hermes using a dedicated MVP command. + +**Files:** +- Create: `src/agent_learner/adapters/hermes_context.py` +- Modify: `src/agent_learner/cli/main.py` +- Modify: `src/agent_learner/core/retrieval.py` only if needed for adapter filtering consistency +- Test: `tests/test_retrieval_adapter_filter.py` +- Test: `tests/test_cli_bootstrap.py` + +**Step 1: Create Hermes retrieval formatter** + +Implement `src/agent_learner/adapters/hermes_context.py` with a small, bounded formatter that: +- retrieves approved rules relevant to the prompt +- tags retrieval request with `adapter="hermes"` if needed +- returns either: + - structured JSON payload for Hermes, or + - compact text block if that is easier to consume + +Keep payload small and deterministic. + +**Step 2: Add `render-hermes-context` CLI command** + +Update `src/agent_learner/cli/main.py` to parse and execute: +```bash +agent-learner render-hermes-context --project-root . --prompt "..." --format hook-json +``` + +Mirror the structure of `render-codex-context` where reasonable, but do not force Codex-specific output fields if Hermes needs a different shape. + +**Step 3: Run targeted retrieval tests** + +Run: +```bash +pytest tests/test_retrieval_adapter_filter.py -v +pytest tests/test_cli_bootstrap.py -k "render_hermes or hermes" -v +``` +Expected: PASS. + +**Step 4: Run broader retrieval regressions** + +Run: +```bash +pytest tests/test_retrieval.py tests/test_context.py tests/test_cli_bootstrap.py -k "render or retrieve" -v +``` +Expected: PASS — existing retrieval behavior still works. + +**Step 5: Commit** + +```bash +git add src/agent_learner/adapters/hermes_context.py src/agent_learner/cli/main.py src/agent_learner/core/retrieval.py tests/test_retrieval_adapter_filter.py tests/test_cli_bootstrap.py +git commit -m "feat: add Hermes retrieval formatter and render command" +``` + +--- + +## Task 8: Add Hermes helper scripts and installer assertions + +**Objective:** Ensure installed Hermes adapter assets include helper scripts for session-end capture and prompt-time retrieval. + +**Files:** +- Modify: `src/agent_learner/adapters/hermes.py` +- Test: `tests/test_installers.py` + +**Step 1: Implement helper script templates** + +In `src/agent_learner/adapters/hermes.py`, add script templates similar to Codex/Claude helpers: +- auto session learning helper +- prompt context helper + +The scripts should: +- read stdin JSON safely +- detect project root +- invoke shared CLI through `python -m agent_learner.cli.main` or installed binary fallback +- tolerate missing transcript path +- fail soft on timeout/errors + +**Step 2: Update installer tests to inspect script content** + +Assert that generated script bodies include: +- `capture-event` +- `process-events` +- `render-hermes-context` +- `--adapter hermes` + +**Step 3: Run installer tests** + +Run: +```bash +pytest tests/test_installers.py -k hermes -v +``` +Expected: PASS. + +**Step 4: Run full installer/bootstrap slice** + +Run: +```bash +pytest tests/test_installers.py tests/test_cli_bootstrap.py -k "install or bootstrap" -v +``` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/agent_learner/adapters/hermes.py tests/test_installers.py tests/test_cli_bootstrap.py +git commit -m "feat: add Hermes helper scripts and installer coverage" +``` + +--- + +## Task 9: Add smoke coverage for manual Hermes MVP flow + +**Objective:** Provide an end-to-end testable path for the experimental Hermes adapter without requiring full Hermes runtime integration. + +**Files:** +- Modify: `src/agent_learner/cli/main.py` +- Modify: `tests/test_cli_bootstrap.py` + +**Step 1: Decide smoke surface** + +Choose one of these minimal MVP smoke options: +- Option A: add `qa-hermes-smoke` +- Option B: keep smoke coverage inside existing CLI tests only and document the manual commands + +Preferred MVP: Option A if it is small and mirrors current Codex/Claude smoke style. + +**Step 2: Implement minimal smoke path** + +If adding `qa-hermes-smoke`, make it: +- create temp/project-local learning state +- seed one approved Hermes-compatible rule +- run `render-hermes-context` +- emit one `session_end` capture/process cycle +- return JSON summary with `returncode == 0` + +If not adding a CLI smoke command, add a test that runs the equivalent sequence through existing commands. + +**Step 3: Run smoke coverage** + +Run: +```bash +pytest tests/test_cli_bootstrap.py -k "hermes and smoke" -v +``` +Expected: PASS. + +**Step 4: Run regression slices** + +Run: +```bash +pytest tests/test_cli_bootstrap.py -k "qa_codex_smoke or qa_claude_smoke or hermes" -v +``` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/agent_learner/cli/main.py tests/test_cli_bootstrap.py +git commit -m "test: add Hermes MVP smoke coverage" +``` + +--- + +## Task 10: Final docs sync inside `.dev` + +**Objective:** Keep draft documentation aligned with the actual implemented MVP before any promotion to `docs/`. + +**Files:** +- Modify: `.dev/prd/hermes-adapter.md` +- Modify: `.dev/design/hermes-adapter-implementation.md` +- Modify: `.dev/reviews/hermes-adapter-review-notes.md` + +**Step 1: Update draft docs with implementation reality** + +Reflect final decisions such as: +- exact install paths +- actual Hermes helper script locations +- whether `qa-hermes-smoke` exists +- whether user scope remained signature-only or gained real coverage +- exact output format of `render-hermes-context` + +**Step 2: Run a final focused test bundle** + +Run: +```bash +pytest \ + tests/test_installers.py \ + tests/test_cli_bootstrap.py \ + tests/test_retrieval_adapter_filter.py -v +``` +Expected: PASS. + +**Step 3: Run a broader confidence suite** + +Run: +```bash +pytest \ + tests/test_retrieval.py \ + tests/test_context.py \ + tests/test_pipeline.py \ + tests/test_pipeline_auto.py \ + tests/test_installers.py \ + tests/test_cli_bootstrap.py -v +``` +Expected: PASS — no obvious regression in nearby core paths. + +**Step 4: Commit** + +```bash +git add .dev/prd/hermes-adapter.md .dev/design/hermes-adapter-implementation.md .dev/reviews/hermes-adapter-review-notes.md tests/test_installers.py tests/test_cli_bootstrap.py tests/test_retrieval_adapter_filter.py src/agent_learner/adapters/hermes.py src/agent_learner/adapters/hermes_context.py src/agent_learner/adapters/__init__.py src/agent_learner/cli/main.py + git commit -m "feat: add experimental Hermes adapter MVP" +``` + +--- + +## Final verification checklist + +- [ ] bootstrap --adapters hermes works in project scope +- [ ] Hermes installer is idempotent +- [ ] `.agent-learner/events/hermes/` is created correctly +- [ ] `capture-event --adapter hermes --event-name session_end` works +- [ ] `process-events --adapter hermes` works +- [ ] `render-hermes-context` returns bounded learned guidance +- [ ] Codex installer tests still pass +- [ ] Claude installer tests still pass +- [ ] Codex/Claude CLI smoke paths still pass +- [ ] `.dev` draft docs match implementation reality + +## Execution handoff + +Plan complete and saved. Ready to execute using subagent-driven-development — dispatch a fresh subagent per task with spec review first and code-quality review second. diff --git a/.dev/prd/hermes-adapter.md b/.dev/prd/hermes-adapter.md new file mode 100644 index 0000000..2929e0f --- /dev/null +++ b/.dev/prd/hermes-adapter.md @@ -0,0 +1,415 @@ +# PRD: Hermes Adapter for agent-learner + +**작성일**: 2026-04-27 15:38 KST +**상태**: 초안 +**범위**: `.dev` 전용 AI 초안 / 사람 승인 전 +**의존성**: agent-learner v2 core, existing Codex/Claude adapters + +--- + +## 1. 목표 + +`agent-learner`를 하네스 독립적인 learning control plane으로 유지하면서, Hermes가 별도 legacy learner를 재구현하지 않고도 동일한 학습 파이프라인을 사용할 수 있게 한다. + +핵심 결과: +- Hermes 세션에서 학습 이벤트를 정규화하여 `agent-learner`로 전달한다. +- Hermes 프롬프트 처리 시 approved learning을 retrieval-first 방식으로 주입한다. +- Hermes의 기존 memory / skills / session_search와 충돌하지 않는 역할 분리를 확립한다. +- 장기적으로 Hermit legacy learner 제거 및 공용 adapter 전략의 기반을 마련한다. + +--- + +## 2. 배경 + +현재 `agent-learner`는 이미 Codex/Claude adapter를 통해 하네스별 hook 차이를 얇은 glue code로 흡수하고, 학습 자산의 생명주기와 retrieval을 shared core에서 처리하는 방향으로 발전하고 있다. + +확인된 현황: +- `src/agent_learner/adapters/codex.py` +- `src/agent_learner/adapters/claude.py` +- `src/agent_learner/adapters/common.py` +- `src/agent_learner/adapters/codex_context.py` +- `src/agent_learner/core/events.py` +- `src/agent_learner/core/pipeline_auto.py` +- `src/agent_learner/core/retrieval.py` + +반면 Hermes는 현재: +- user profile / durable memory 저장 +- reusable skills 저장 +- session transcript / session_search 기반 회상 +에 강점이 있지만, +프로젝트 로컬 행동 규칙을 자동 수집·검토·승격·주입하는 shared learning plane은 아직 없다. + +따라서 Hermes에 필요한 것은 새로운 독자 learner가 아니라, `agent-learner`에 연결되는 얇은 adapter 계층이다. + +--- + +## 3. 문제 정의 + +Hermes에는 다음 공백이 있다. + +1. 세션 종료 후 학습 가능한 패턴을 표준 이벤트로 수집하는 경로가 없다. +2. 프로젝트별 approved rule을 다음 턴에 retrieval-first 방식으로 주입하는 표준 경로가 없다. +3. memory / skills / project-local learning rule의 경계가 명확히 문서화되어 있지 않다. +4. 장기적으로 여러 하네스(Hermes, Hermit, Codex, Claude)가 같은 learning plane을 공유하려면 adapter contract가 더 명시적이어야 한다. + +--- + +## 4. 제품 원칙 + +### 4.1 Shared core, thin adapters +- 학습 규칙의 추출 / lifecycle / retrieval / history는 `agent-learner` core가 담당한다. +- adapter는 하네스별 hook 연결, event normalization, prompt injection boundary만 담당한다. + +### 4.2 Retrieval-first +- 모든 학습 자산을 프롬프트에 고정 주입하지 않는다. +- 현재 작업, cwd, session context에 맞는 approved rule만 top-N으로 주입한다. +- prompt bloat를 방지한다. + +### 4.3 Role separation +- Hermes memory = 사용자 선호 / 환경 사실 / durable profile +- Hermes skills = 수동 또는 준정적 재사용 절차 +- agent-learner rules = 프로젝트 로컬 행동 규칙 / 작업 패턴 / 검증된 learned feedback + +### 4.4 Local-first with optional global fanout +- 기본값은 프로젝트 로컬 학습 우선 +- 장기적으로 사용자 범위 fanout / global retrieval은 열어둘 수 있으나 MVP 필수는 아니다. + +--- + +## 5. 목표 범위 + +### In scope +- Hermes adapter 설치 경로 정의 +- Hermes 이벤트 캡처 포맷 정의 +- Hermes용 retrieval injection 경로 정의 +- CLI surface에 Hermes adapter 추가 +- 프로젝트 로컬 learning 디렉터리 초기화 +- history / review / dashboard와의 호환성 확보 +- MVP 범위에서의 명시적 제품 결정 문서화 + - project scope 우선 + - `session_end` 이벤트 우선 + - experimental adapter로 시작 + +### Out of scope +- Hermes 자체 memory 시스템 대체 +- Hermes skill 시스템 대체 +- 자동 승인 정책의 대폭 변경 +- 장기 실행 autoresearch 기능 구현 +- Hermit legacy 제거 자체를 이번 작업에서 완료 +- MVP에서 user-scope default 도입 +- Hermes용 별도 장기 실행 background learner 추가 + +--- + +## 6. 사용자 가치 + +### 6.1 Hermes 사용자 +- 반복적으로 교정한 작업 방식이 프로젝트 단위로 축적된다. +- 다음 세션에서 relevant rule만 자동 회수된다. +- 수동 스킬 작성 전에 실제 반복 패턴을 더 자연스럽게 포착할 수 있다. + +### 6.2 agent-learner 유지보수자 +- 새 하네스 지원이 “별도 learner 구현”이 아니라 “adapter 추가” 문제가 된다. +- Codex/Claude/Hermes/Hermit 사이의 수렴 전략이 분명해진다. + +### 6.3 장기 제품 전략 +- legacy Hermit learner를 제거하고 v2를 공용 오픈소스 라이브러리로 정리하기 쉬워진다. + +--- + +## 7. 제안 아키텍처 + +```text +Hermes session/runtime + ├─ emits normalized events + ├─ invokes agent-learner process step + └─ requests retrieval context before prompt execution + +agent-learner Hermes adapter + ├─ bootstrap --adapters hermes + ├─ capture-event --adapter hermes + ├─ process-events --adapter hermes + └─ render-hermes-context (or shared retrieval formatter) + +agent-learner shared core + ├─ events/ + ├─ candidates/ + ├─ learning/{approved,needs_review,deprecated,...} + ├─ history/ + ├─ index/ + └─ state/ +``` + +--- + +## 8. Hermes adapter 기능 요구사항 + +### 8.1 설치 +새 adapter installer를 제공한다. + +예상 CLI: +```bash +agent-learner bootstrap --adapters hermes --target --hermes-scope project|user +``` + +역할: +- Hermes와 연동할 설정/훅 파일 생성 또는 patch +- `.agent-learner/` 초기 디렉터리 보장 +- 필요 시 `.gitignore` 갱신 +- Hermes용 안내 문서/샘플 자산 배치 + +### 8.2 이벤트 캡처 +Hermes 세션의 적절한 종료/완료 시점에 normalized event를 기록한다. + +MVP 결정: +- 1차 이벤트는 `session_end` 하나로 시작한다. +- `task_complete` 같은 세분화 이벤트는 후속 단계에서 추가 검토한다. +- transcript가 없어도 summary-only event를 허용한다. + +최소 필드: +- `adapter`: `hermes` +- `event_name`: `session_end` +- `cwd` +- `session_id` +- `model` +- `timestamp` +- `payload` + +`payload` 후보: +- user request summary +- assistant final summary +- tool usage summary +- verification result +- success/failure marker +- relevant transcript/session file path + +예상 CLI: +```bash +agent-learner capture-event \ + --adapter hermes \ + --event-name session_end \ + --project-root . \ + --session-id +``` + +### 8.3 이벤트 처리 +캡처 직후 shared core pipeline을 실행한다. + +예상 CLI: +```bash +agent-learner process-events --adapter hermes --limit 1 +``` + +역할: +- raw event 로드 +- candidate 추출 +- scoring / auto-classification +- approved / needs_review / deprecated lifecycle 반영 +- processed marker 기록 + +### 8.4 프롬프트 시점 retrieval +Hermes가 새 user request를 처리하기 직전에 relevant rule을 조회한다. + +요구사항: +- retrieval-first +- top-N 제한 +- cwd/task/session signal 반영 +- memory/skills보다 먼저 또는 뒤에 넣을지 ordering을 명시 +- injected context가 너무 크면 truncation / ranking 적용 + +MVP 결정: +- Hermes 전용 `render-hermes-context` 커맨드로 먼저 시작한다. +- 장기적으로는 `render-context --adapter hermes` 같은 공통 surface로 수렴할 수 있다. +- 기본 주입 위치는 memory/skills 이후의 compact learned-guidance block으로 가정하되, 실제 Hermes runtime 제약을 확인한 뒤 확정한다. + +예상 인터페이스: +```bash +agent-learner render-hermes-context --project-root . --cwd --session-id +``` +또는 shared retrieval API를 Hermes 런타임에서 직접 호출. + +### 8.5 Review/history/dashboard 호환 +Hermes adapter가 생성한 이벤트와 후보는 기존 review/history/dashboard에서 보이도록 한다. + +즉 다음이 모두 adapter=hermes를 지원해야 한다. +- `review-candidates` +- `history` +- `history-summary` +- dashboard filters + +--- + +## 9. 저장소 / 경로 제안 + +### agent-learner repo 내부 +새 파일 후보: +- `src/agent_learner/adapters/hermes.py` +- `src/agent_learner/adapters/hermes_context.py` 또는 공통 retrieval formatter 재사용 +- `plugins/hermes/README.md` +- `docs/install.md` 업데이트 (승인 후) +- `docs/quickstart.md` 업데이트 (승인 후) +- `docs/adapter-convergence.md` 업데이트 (승인 후) + +### consumer repo / runtime 쪽 +프로젝트 루트 기준: +```text +.agent-learner/ + events/hermes/ + candidates/ + learning/ + history/ + index/ + state/ +``` + +--- + +## 10. Hermes와 기존 memory/skills의 관계 + +혼동을 막기 위한 명시적 규칙이 필요하다. + +### memory에 남겨야 하는 것 +- 사용자 선호 +- 계정/환경 관련 durable facts +- 프로젝트 전반에 걸친 안정적 사실 + +### skill로 남겨야 하는 것 +- 사람이 의도적으로 재사용하고 싶은 절차 +- 다단계 운영 runbook +- 비슷한 작업에서 반복 호출할 workflow + +### agent-learner rule로 남겨야 하는 것 +- 특정 repo 또는 작업 맥락에서 반복적으로 유효한 행동 교정 +- 검증을 통해 쓸모가 입증된 learned feedback +- “이 프로젝트에서는 이런 식으로 접근해야 한다”에 가까운 규칙 + +원칙: +- 모든 반복 패턴을 memory/skill로 승격하지 않는다. +- 자동 수집된 rule은 기본적으로 project-local 자산으로 본다. + +--- + +## 11. MVP 제안 + +### Phase 1 — adapter skeleton +목표: +- Hermes adapter 파일 추가 +- CLI에 `bootstrap --adapters hermes` 경로 노출 +- `capture-event/process-events`가 `adapter=hermes`를 받도록 확장 +- adapter를 experimental 상태로 노출 + +검증: +- 설치 명령 성공 +- `.agent-learner/events/hermes/`에 raw event 생성 +- processing 후 processed marker 생성 +- 기존 codex/claude install path 회귀 없음 + +### Phase 2 — session end learning +목표: +- Hermes 세션 종료 시 자동 `capture-event` +- 후속 `process-events` +- candidate 생성 및 lifecycle 반영 + +검증: +- 실제 Hermes 세션 1회 후 candidate/learning/history 변화 확인 +- `review-candidates --adapter hermes`에서 결과 노출 + +### Phase 3 — prompt-time retrieval +목표: +- 새 요청 처리 직전 approved learning retrieval +- top-N context injection +- prompt bloat 방지 규칙 적용 + +검증: +- 동일 프로젝트 후속 세션에서 approved rule이 relevant할 때만 주입 +- irrelevant한 프로젝트에서는 주입되지 않음 + +### Phase 4 — convergence hardening +목표: +- Codex/Claude/Hermes adapter contract 정리 +- 공통 formatter / installer / event schema 정돈 +- Hermit migration 경로 문서화 + +검증: +- adapter별 차이가 hook boundary로 제한됨 +- 공통 dashboard/history에서 adapter filter만으로 비교 가능 + +--- + +## 12. 구현 가이드라인 + +1. Hermes adapter는 가능하면 `common.py` 유틸을 재사용한다. +2. 새로운 core 개념을 만들기 전에 Codex/Claude 경로와 수렴시킨다. +3. 이벤트 스키마는 `core/events.py`의 normalized contract를 따른다. +4. retrieval formatting은 Hermes prompt 구조에 맞추되, ranking/retrieval 로직은 core에서 유지한다. +5. installer는 idempotent해야 한다. +6. Hermes 전용 구현이 shared core로 올라갈 수 있으면 먼저 core화를 검토한다. + +--- + +## 13. 성공 기준 + +### 기능 성공 +- Hermes에서 설치 가능한 adapter surface가 존재한다. +- Hermes 세션 종료 후 학습 이벤트가 자동 수집된다. +- approved learning이 후속 Hermes 요청에 retrieval-first 방식으로 주입된다. +- review/history/dashboard에서 adapter=hermes가 일관되게 동작한다. + +### 제품 성공 +- Hermes 쪽에 별도 legacy learner를 만들 필요가 없어진다. +- `agent-learner`가 실제로 multi-harness learning plane 역할을 수행한다. +- 이후 Hermit migration 논의에서 Hermes adapter가 선행 증거가 된다. + +--- + +## 14. 리스크 + +1. Hermes memory/skills와 learning rule의 역할이 사용자에게 혼동될 수 있다. +2. retrieval injection ordering이 잘못되면 prompt bloat 또는 instruction conflict가 생길 수 있다. +3. Hermes session transcript 구조가 Codex/Claude와 달라 candidate extraction 품질이 떨어질 수 있다. +4. adapter별로 예외 처리가 늘어나면 shared core보다 adapter-local logic가 다시 비대해질 수 있다. + +대응: +- 역할 구분 문서화 +- top-N + truncation 강제 +- raw event schema를 먼저 최소 단위로 정규화 +- 공통 contract 위반 시 adapter-local workaround를 임시로만 허용 + +--- + +## 15. 오픈 질문 + +1. Hermes의 가장 안정적인 hook 지점은 어디인가? +2. Hermes session transcript에서 extraction에 필요한 최소 필드는 무엇인가? +3. retrieval context를 Hermes system prompt / tool preamble / user-message preprocessor 중 어디에 넣는 것이 가장 적절한가? +4. Hermes adapter는 project scope만 먼저 지원할지, user scope까지 MVP에 포함할지? +5. Hermit migration과 Hermes adapter 작업 순서를 어떻게 조정할지? + +--- + +## 16. 출시 가드레일 + +MVP를 merge-ready로 보기 위한 최소 조건: +- Codex/Claude 기존 install 및 smoke path 회귀 없음 +- Hermes adapter install이 idempotent함 +- manual capture/process 흐름이 재현 가능함 +- retrieval output이 bounded size를 유지함 +- adapter가 Hermes memory/skills 파일을 자동으로 변경하지 않음 +- `.dev` 문서의 핵심 결정(project scope 우선, session_end 우선, experimental rollout)이 구현/README와 일치함 + +--- + +## 17. 다음 문서/작업 연결 + +이 초안이 승인되면 이후 작업: +1. `docs/adapter-convergence.md`에 Hermes 추가 +2. `docs/install.md`에 Hermes 설치 경로 반영 +3. `docs/quickstart.md`에 Hermes 예시 추가 +4. `src/agent_learner/adapters/hermes.py` 구현 시작 +5. adapter contract를 별도 문서로 분리할지 검토 + +--- + +## 18. 한 줄 요약 + +Hermes는 자체 learner를 새로 만들지 말고, `agent-learner v2`의 shared learning plane에 붙는 세 번째 정식 adapter로 들어가는 것이 바람직하다. diff --git a/CHANGELOG.md b/CHANGELOG.md index ae8796c..9d3d2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by Keep a Changelog and is intentionally lightweight whil ## [Unreleased] +### 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. + ## [0.3.22] - 2026-04-25 ### Fixed diff --git a/README.md b/README.md index 3fe95db..9cca441 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,9 @@ Docker is optional convenience only. It is not the primary OSS install path. ## Typical workflow -1. Install the Codex hook once at user scope or per project +1. Install the adapter you want to use first + - Codex is the most established path + - Hermes is available as an explicit experimental opt-in 2. Run `doctor` 3. Open the dashboard 4. Review rules, candidates, and history @@ -97,17 +99,21 @@ Static dashboard generation and stdlib-only serving still exist, but they are se ```bash agent-learner doctor --project-root /path/to/repo agent-learner dashboard --project-root /path/to/repo --open -agent-learner install-codex -agent-learner bootstrap --target /path/to/repo +agent-learner bootstrap +agent-learner bootstrap --adapters hermes agent-learner review-candidates --project-root /path/to/repo agent-learner history --project-root /path/to/repo --latest-per-rule --last 10 agent-learner history-summary --project-root /path/to/repo --by adapter-decision agent-learner overview --project-root /path/to/repo --format json agent-learner rebuild-index --project-root /path/to/repo -agent-learner install-codex --target /path/to/repo agent-learner update ``` +Bootstrap note: +- `agent-learner bootstrap` is now the only install entrypoint +- default `bootstrap` installs `codex,claude,hermes` +- use `agent-learner bootstrap --adapters hermes` if you only want Hermes + ## Repository shape - `src/agent_learner/` — Python core @@ -151,6 +157,7 @@ Current implemented areas: - dashboard UI - global promotion and sync - npm wrapper + source checkout helper +- Hermes experimental adapter with user-scope config.yaml-based hook wiring and runtime smoke coverage ## Release note @@ -175,10 +182,9 @@ Then follow `docs/publish-smoke-checklist.md`. Common wrapper aliases now work directly: ```bash -agent-learner install-codex --target "$PWD" -agent-learner install-claude --target "$PWD" agent-learner rebuild-index --project-root "$PWD" -agent-learner bootstrap --target "$PWD" +agent-learner bootstrap +agent-learner bootstrap --adapters hermes agent-learner update agent-learner completion zsh ``` diff --git a/docs/install.md b/docs/install.md index ecd3ed3..4ae9e7e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -100,22 +100,24 @@ or required OSS installation path. ## Codex adapter +Primary install path for Codex only: + ```bash -agent-learner install-codex --target /path/to/consumer-repo +agent-learner bootstrap --adapters codex --target /path/to/consumer-repo ``` -By default, `install-codex` installs the Codex hook once for your user-level +By default, `bootstrap --adapters codex` installs the Codex hook once for your user-level Codex home so every project can learn into its own local `.agent-learner/` tree automatically: ```bash -agent-learner install-codex +agent-learner bootstrap --adapters codex ``` If you want the older repo-local hook install, opt into it explicitly: ```bash -agent-learner install-codex --scope project --target /path/to/consumer-repo +agent-learner bootstrap --adapters codex --codex-scope project --target /path/to/consumer-repo ``` This creates: @@ -162,8 +164,10 @@ agent-learner qa-codex-smoke ## Claude adapter +Primary install path for Claude only: + ```bash -agent-learner install-claude --target /path/to/consumer-repo +agent-learner bootstrap --adapters claude --target /path/to/consumer-repo ``` This creates: @@ -172,21 +176,101 @@ This creates: - `.claude/skills/session-wrap/` - `.claude/skills/feedback-learning/` +## Hermes adapter (experimental) + +Hermes support is currently experimental, but the default install path is now user scope so +it matches Codex and Claude. + +Recommended path: + +```bash +agent-learner bootstrap +``` + +If you only want Hermes instead of the full default bootstrap set: + +```bash +agent-learner bootstrap --adapters hermes +``` + +This creates user-scope Hermes hook assets under `~/.hermes/`: +- `~/.hermes/config.yaml` when it does not already exist +- `~/.hermes/config.agent-learner.yaml` +- `~/.hermes/AGENT_LEARNER_README.md` +- `~/.hermes/hooks/auto_session_learning.py` +- `~/.hermes/hooks/hermes_prompt_context.py` + +The Hermes adapter wires two real Hermes shell hooks: +- `pre_llm_call` -> retrieve compact learned guidance with `render-hermes-context` +- `on_session_end` -> capture a normalized `session_end` event and process it through the shared pipeline + +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 +- `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. + +If you explicitly want an isolated project-local Hermes home instead, that path is still available: + +```bash +agent-learner bootstrap --adapters hermes --hermes-scope project --target /path/to/consumer-repo +HERMES_HOME=/path/to/consumer-repo/.hermes hermes --accept-hooks +``` + +Preview the learned prompt injection locally: + +```bash +agent-learner render-hermes-context \ + --project-root /path/to/consumer-repo \ + --prompt "update hermes bootstrap wiring and keep tests green" +``` + +For the actual Hermes hook wire shape: + +```bash +agent-learner render-hermes-context \ + --project-root /path/to/consumer-repo \ + --prompt "update hermes bootstrap wiring and keep tests green" \ + --format hook-json +``` + +This prints Hermes-compatible JSON like `{"context": "..."}` for `pre_llm_call`. + +Run the Hermes smoke path: + +```bash +agent-learner qa-hermes-smoke +``` + +The smoke now verifies both direct script execution and real Hermes runtime wiring with +`hermes hooks list`, `hermes hooks doctor`, and `hermes hooks test` in an isolated project. + +If you want Hermes-only onboarding instead of the default bootstrap: + +```bash +agent-learner bootstrap --adapters hermes +``` + ## One-command onboarding ```bash -agent-learner bootstrap --target /path/to/consumer-repo +agent-learner bootstrap ``` Default adapters: - codex - claude +- hermes Customize if needed: ```bash -agent-learner bootstrap --target /path/to/consumer-repo --adapters codex -agent-learner bootstrap --target /path/to/consumer-repo --adapters claude +agent-learner bootstrap --adapters codex +agent-learner bootstrap --adapters claude +agent-learner bootstrap --adapters hermes ``` ## Independence guarantee @@ -287,11 +371,10 @@ These commands verify the expected adapter files/directories exist after install Common wrapper aliases now work directly: ```bash -agent-learner install-codex --target "$PWD" -agent-learner install-codex -agent-learner install-claude --target "$PWD" +agent-learner bootstrap +agent-learner bootstrap --adapters codex +agent-learner bootstrap --adapters hermes agent-learner rebuild-index --project-root "$PWD" -agent-learner bootstrap --target "$PWD" agent-learner update agent-learner completion zsh ``` diff --git a/docs/prerelease-checklist.md b/docs/prerelease-checklist.md index 11e1bbb..0df49ad 100644 --- a/docs/prerelease-checklist.md +++ b/docs/prerelease-checklist.md @@ -53,6 +53,7 @@ Then verify: ```bash npx @cafitac/agent-learner@next version npx @cafitac/agent-learner@next doctor +npx @cafitac/agent-learner@next bootstrap --adapters codex --target /path/to/consumer-repo AGENT_LEARNER_UVX_INDEX_URL=https://test.pypi.org/simple \ AGENT_LEARNER_UVX_EXTRA_ARGS="--refresh --with fastapi<1 --with uvicorn<1 --index-strategy unsafe-best-match" \ npx @cafitac/agent-learner@next core --help diff --git a/docs/publish-smoke-checklist.md b/docs/publish-smoke-checklist.md index 45e30ea..01e2179 100644 --- a/docs/publish-smoke-checklist.md +++ b/docs/publish-smoke-checklist.md @@ -80,6 +80,7 @@ Expected: ```bash npx @cafitac/agent-learner doctor npx @cafitac/agent-learner dashboard --project-root /path/to/consumer-repo +npx @cafitac/agent-learner bootstrap --adapters codex --target /path/to/consumer-repo ``` Expected: @@ -87,6 +88,7 @@ Expected: - wrapper launches - `doctor` reports a dashboard-oriented verdict/advice - `dashboard` delegates correctly into the Python core +- `bootstrap --adapters codex` reaches the Python core and uses the bootstrap-only install path ## 4. Source checkout helper smoke (recommended) diff --git a/docs/quickstart.md b/docs/quickstart.md index 0649c32..a55d025 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -54,13 +54,38 @@ Docker is optional convenience only. The primary path is still `agent-learner da ```bash agent-learner rebuild-index --project-root "$PWD" -agent-learner bootstrap --target /path/to/consumer-repo +agent-learner bootstrap agent-learner review-candidates --project-root /path/to/consumer-repo agent-learner history --project-root /path/to/consumer-repo --latest-per-rule --last 10 agent-learner history-summary --project-root /path/to/consumer-repo --by adapter-decision agent-learner overview --project-root /path/to/consumer-repo --format json ``` +## Hermes experimental quickstart + +If you want to try the Hermes adapter specifically, use the bootstrap path: + +```bash +agent-learner bootstrap --adapters hermes +agent-learner render-hermes-context --project-root "$PWD" --prompt "update hermes bootstrap wiring and keep tests green" +agent-learner render-hermes-context --project-root "$PWD" --prompt "update hermes bootstrap wiring and keep tests green" --format hook-json +agent-learner qa-hermes-smoke +``` + +If you explicitly want an isolated project-local Hermes home instead: + +```bash +agent-learner bootstrap --adapters hermes --hermes-scope project --target "$PWD" +HERMES_HOME=.hermes hermes --accept-hooks +``` + +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. +- `qa-hermes-smoke` now checks direct script output plus `hermes hooks list/doctor/test` runtime wiring. + ## If you are validating a release ```bash @@ -76,10 +101,9 @@ Then follow `docs/publish-smoke-checklist.md`. Common wrapper aliases now work directly: ```bash -agent-learner install-codex --target "$PWD" -agent-learner install-claude --target "$PWD" agent-learner rebuild-index --project-root "$PWD" -agent-learner bootstrap --target "$PWD" +agent-learner bootstrap +agent-learner bootstrap --adapters hermes agent-learner update agent-learner completion zsh ``` diff --git a/docs/release-process.md b/docs/release-process.md index b3e7c2d..8376c13 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -28,6 +28,7 @@ Run the real user-facing smoke paths in this order: 1. `pipx install "agent-learner[web]"` -> `agent-learner doctor` -> `agent-learner dashboard` 2. `uvx --from "agent-learner[web]" agent-learner doctor` 3. `npx @cafitac/agent-learner doctor` +4. `npx @cafitac/agent-learner bootstrap --adapters codex --target /path/to/consumer-repo` Use `docs/publish-smoke-checklist.md` for the exact matrix and optional paths. diff --git a/lib/wrapper.cjs b/lib/wrapper.cjs index c90c4ee..a3836d5 100644 --- a/lib/wrapper.cjs +++ b/lib/wrapper.cjs @@ -63,8 +63,13 @@ const TOP_LEVEL_CORE_COMMANDS = new Set([ 'generate-dashboard', ]); -const COMPLETION_COMMANDS = ['dashboard', 'doctor', 'version', 'install-codex', 'install-claude', 'rebuild-index', 'update', 'completion', 'core', 'codex', 'claude']; +const COMPLETION_COMMANDS = ['bootstrap', 'dashboard', 'doctor', 'version', 'rebuild-index', 'update', 'completion', 'core', 'codex', 'claude']; const CORE_COMPLETION_COMMANDS = ['bootstrap', 'rebuild-index', 'review-candidates', 'review-candidate', 'history', 'history-summary', 'overview', 'dashboard-summary', 'generate-dashboard']; +const REMOVED_INSTALL_REPLACEMENTS = { + 'install-codex': 'agent-learner bootstrap --adapters codex', + 'install-claude': 'agent-learner bootstrap --adapters claude', + 'install-hermes': 'agent-learner bootstrap --adapters hermes', +}; function completionScript(shell) { if (shell === 'zsh') { @@ -73,11 +78,10 @@ function completionScript(shell) { _agent_learner() { local -a commands commands=( + 'bootstrap:Install default adapters or a selected subset' 'dashboard:Open the dashboard' 'doctor:Show readiness information' 'version:Print wrapper version' - 'install-codex:Install Codex learning hooks' - 'install-claude:Install Claude learning hooks' 'rebuild-index:Rebuild rule indexes' 'update:Update the npm wrapper globally' 'completion:Print shell completion script' @@ -92,18 +96,15 @@ _agent_learner() { fi case "$words[2]" in + bootstrap) + _arguments '--target[Target project root]:path:_files -/' '--adapters[Adapters]:adapters:(codex claude hermes codex,claude codex,hermes claude,hermes codex,claude,hermes)' '--codex-scope[Codex install scope]:scope:(project user)' '--claude-scope[Claude install scope]:scope:(project user)' '--hermes-scope[Hermes install scope]:scope:(project user)' + ;; dashboard) _arguments '--project-root[Project root]:path:_files -/' '--open[Open browser]' '--port[Port]:port:' '--build[Force frontend build]' '--no-build[Disable auto build]' ;; doctor) _arguments '--json[Emit JSON]' ;; - install-codex) - _arguments '--target[Target install root]:path:_files -/' '--scope[Install scope]:scope:(project user)' - ;; - install-claude) - _arguments '--target[Target project root]:path:_files -/' - ;; rebuild-index) _arguments '--project-root[Project root]:path:_files -/' '--scope[Scope]:scope:(project global both)' '--format[Output format]:format:(text json)' ;; @@ -146,7 +147,7 @@ _agent_learner "$@" local cur prev words cword _init_completion || return - local commands="dashboard doctor version install-codex install-claude rebuild-index update completion core codex claude" + local commands="bootstrap dashboard doctor version rebuild-index update completion core codex claude" local core_commands="bootstrap rebuild-index review-candidates review-candidate history history-summary overview dashboard-summary generate-dashboard" if [[ $cword -eq 1 ]]; then @@ -155,18 +156,15 @@ _agent_learner "$@" fi case "\${words[1]}" in + bootstrap) + COMPREPLY=( $(compgen -W "--target --adapters --codex-scope --claude-scope --hermes-scope" -- "$cur") ) + ;; dashboard) COMPREPLY=( $(compgen -W "--project-root --open --port --build --no-build" -- "$cur") ) ;; doctor) COMPREPLY=( $(compgen -W "--json" -- "$cur") ) ;; - install-codex) - COMPREPLY=( $(compgen -W "--target --scope" -- "$cur") ) - ;; - install-claude) - COMPREPLY=( $(compgen -W "--target" -- "$cur") ) - ;; rebuild-index) COMPREPLY=( $(compgen -W "--project-root --scope --format" -- "$cur") ) ;; @@ -205,9 +203,8 @@ function printHelp(packageRoot = packageRootFromModuleDir()) { console.log(`agent-learner npm wrapper v${version} Usage: + agent-learner bootstrap [--target ] [--adapters ] [--codex-scope ] [--claude-scope ] [--hermes-scope ] agent-learner dashboard [--project-root ] [--open] [--port ] [--no-build] - agent-learner install-codex [--target ] [--scope ] - agent-learner install-claude [--target ] [--scope ] agent-learner rebuild-index [--project-root ] [--scope ] [--format ] agent-learner update agent-learner codex install [--target ] [--scope ] @@ -238,6 +235,9 @@ function parseArgs(argv) { if (lane === '--version' || lane === '-v' || lane === 'version') { return { type: 'version' }; } + if (REMOVED_INSTALL_REPLACEMENTS[lane]) { + return { type: 'removed-install', command: lane, replacement: REMOVED_INSTALL_REPLACEMENTS[lane] }; + } if (lane === 'doctor') { return { type: 'doctor', json: rest.includes('--json') || action === '--json' }; } @@ -247,21 +247,6 @@ function parseArgs(argv) { if (lane === 'update') { return { type: 'update' }; } - if (lane === 'install-codex' || lane === 'install-claude') { - let target = null; - let scope = null; - const all = [action, ...rest].filter(Boolean); - for (let i = 0; i < all.length; i += 1) { - if (all[i] === '--target') { - target = all[i + 1] || null; - i += 1; - } else if (all[i] === '--scope') { - scope = all[i + 1] || null; - i += 1; - } - } - return { type: 'lane', lane: lane === 'install-codex' ? 'codex' : 'claude', action: 'install', target, scope, json: false }; - } if (lane === 'rebuild-index') { let projectRoot = null; let scope = null; @@ -357,24 +342,24 @@ function mapToCoreArgs(parsed, cwd = process.cwd()) { } if (parsed.lane === 'codex' && parsed.action === 'install') { const scope = parsed.scope || 'user'; - const args = ['install-codex']; + const args = ['bootstrap', '--adapters', 'codex']; if (parsed.target) { args.push('--target', parsed.target); } else if (scope !== 'user') { args.push('--target', defaultTarget(cwd)); } - args.push('--scope', scope); + args.push('--codex-scope', scope); return args; } if (parsed.lane === 'claude' && parsed.action === 'install') { const claudeScope = parsed.scope || 'user'; - const claudeArgs = ['install-claude']; + const claudeArgs = ['bootstrap', '--adapters', 'claude']; if (parsed.target) { claudeArgs.push('--target', parsed.target); } else if (claudeScope !== 'user') { claudeArgs.push('--target', defaultTarget(cwd)); } - claudeArgs.push('--scope', claudeScope); + claudeArgs.push('--claude-scope', claudeScope); return claudeArgs; } const target = parsed.target || defaultTarget(cwd); @@ -401,6 +386,9 @@ function buildExecutionPlan(parsed, packageRoot, cwd = process.cwd()) { if (parsed.type === 'completion') { return { mode: 'completion', command: null, args: [] }; } + if (parsed.type === 'removed-install') { + return { mode: 'removed-install', command: null, args: [] }; + } if (parsed.type === 'update') { return { mode: 'update', command: null, args: [] }; } @@ -689,6 +677,10 @@ function printLaneDoctor(report, jsonMode = false) { } } +function printRemovedInstallMessage(parsed) { + console.error(`[agent-learner] \`${parsed.command}\` was removed. Use: ${parsed.replacement}`); +} + function runCli(argv, { moduleDir = __dirname, cwd = process.cwd(), stdio = 'inherit' } = {}) { const packageRoot = packageRootFromModuleDir(moduleDir); const parsed = parseArgs(argv); @@ -725,6 +717,10 @@ function runCli(argv, { moduleDir = __dirname, cwd = process.cwd(), stdio = 'inh printLaneDoctor(report, parsed.json === true); return report.ok ? 0 : 1; } + if (plan.mode === 'removed-install') { + printRemovedInstallMessage(parsed); + return 2; + } if (plan.mode === 'invalid') { printHelp(packageRoot); return 1; diff --git a/src/agent_learner/cli/main.py b/src/agent_learner/cli/main.py index fd6d89c..d5582cf 100644 --- a/src/agent_learner/cli/main.py +++ b/src/agent_learner/cli/main.py @@ -3,15 +3,17 @@ import argparse import json import os +import shutil import subprocess import tempfile import sys import webbrowser from pathlib import Path -from agent_learner.adapters import install_claude_adapter, install_codex_adapter +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 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.adapters.codex_context import ( build_codex_user_prompt_hook_output, format_retrieval_results_as_json, @@ -44,6 +46,13 @@ from agent_learner.core.webapp import run_dashboard_server +LEGACY_INSTALL_REPLACEMENTS = { + "install-codex": "agent-learner bootstrap --adapters codex", + "install-claude": "agent-learner bootstrap --adapters claude", + "install-hermes": "agent-learner bootstrap --adapters hermes", +} + + def filter_history_entries(entries: list[dict[str, object]], args: argparse.Namespace) -> list[dict[str, object]]: filtered = entries if getattr(args, "rule", None): @@ -78,6 +87,61 @@ def filter_history_entries(entries: list[dict[str, object]], args: argparse.Name return filtered +def emit_hermes_install_guidance(target: Path, *, scope: str, had_config: bool) -> None: + hermes_root = target / ".hermes" + snippet_path = hermes_root / "config.agent-learner.yaml" + readme_path = hermes_root / "AGENT_LEARNER_README.md" + if scope == "project": + print("[agent-learner] Hermes adapter installed in project-local opt-in mode.", file=sys.stderr) + if had_config: + print( + f"[agent-learner] Existing Hermes config preserved; review and merge {snippet_path} into the config you actually run.", + file=sys.stderr, + ) + else: + print( + f"[agent-learner] Project-local Hermes config created at {hermes_root / 'config.yaml'}.", + file=sys.stderr, + ) + print(f"[agent-learner] Safest activation: HERMES_HOME={hermes_root} hermes --accept-hooks", file=sys.stderr) + print("[agent-learner] Project-local HERMES_HOME must also have model/auth configured; otherwise merge the snippet into your normal Hermes config instead.", file=sys.stderr) + print(f"[agent-learner] Notes: {readme_path}", file=sys.stderr) + return + + print("[agent-learner] Hermes user-scope hooks installed.", file=sys.stderr) + if had_config: + print( + f"[agent-learner] Existing Hermes config preserved; review and merge {snippet_path} into your active Hermes config.", + file=sys.stderr, + ) + else: + print(f"[agent-learner] Hermes config created at {hermes_root / 'config.yaml'}.", file=sys.stderr) + print(f"[agent-learner] Notes: {readme_path}", file=sys.stderr) + + +def resolve_install_target(*, target: str | None, scope: str) -> Path: + if target: + return Path(target).expanduser().resolve() + return Path.home() if scope == "user" else Path.cwd().resolve() + + +def exit_for_removed_install_command(parser: argparse.ArgumentParser, command: str) -> None: + replacement = LEGACY_INSTALL_REPLACEMENTS[command] + parser.exit( + 2, + f"[agent-learner] Error: `{command}` was removed. Use `{replacement}` instead.\n", + ) + + +def emit_bootstrap_summary(*, adapters: list[str], used_default_target: bool, scopes: dict[str, str]) -> None: + print(f"[agent-learner] Bootstrap installed adapters: {', '.join(adapters)}", file=sys.stderr) + if used_default_target and all(scopes.get(adapter) == "user" for adapter in adapters): + print( + "[agent-learner] Default bootstrap keeps everything in user scope unless you opt into project scope.", + file=sys.stderr, + ) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="agent-learner") sub = parser.add_subparsers(dest="command") @@ -100,23 +164,19 @@ def build_parser() -> argparse.ArgumentParser: dashboard_cmd.add_argument("--static", action="store_true") dashboard_cmd.add_argument("--open", action="store_true") - codex_cmd = sub.add_parser("install-codex") - codex_cmd.add_argument("--target") - codex_cmd.add_argument("--scope", choices=["project", "user"], default="user") - - claude_cmd = sub.add_parser("install-claude") - claude_cmd.add_argument("--target") - claude_cmd.add_argument("--scope", choices=["project", "user"], default="user") - - bootstrap_cmd = sub.add_parser("bootstrap") - bootstrap_cmd.add_argument("--target", default=".") + bootstrap_cmd = sub.add_parser( + "bootstrap", + description="One-command setup: installs codex, claude, and hermes in user scope by default. Advanced flags are only needed for custom targets or project-local wiring.", + ) + bootstrap_cmd.add_argument("--target", help="Advanced: override the default install root") bootstrap_cmd.add_argument( "--adapters", - default="codex,claude", - help="Comma-separated adapter list: codex, claude", + default="codex,claude,hermes", + help="Advanced: limit bootstrap to a comma-separated subset such as hermes", ) - 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("--codex-scope", choices=["project", "user"], default="user", help="Advanced: override Codex install scope") + bootstrap_cmd.add_argument("--claude-scope", choices=["project", "user"], default="user", help="Advanced: override Claude install scope") + bootstrap_cmd.add_argument("--hermes-scope", choices=["project", "user"], default="user", help="Advanced: override Hermes install scope") promote_cmd = sub.add_parser("promote-demo") promote_cmd.add_argument("--root", default=".agent-learner") @@ -181,22 +241,26 @@ def build_parser() -> argparse.ArgumentParser: claude_smoke_cmd = sub.add_parser("qa-claude-smoke") claude_smoke_cmd.add_argument("--project-root", default=".") + hermes_smoke_cmd = sub.add_parser("qa-hermes-smoke") + hermes_smoke_cmd.add_argument("--project-root", default=".") + hermes_smoke_cmd.add_argument("--prompt", default="update hermes bootstrap wiring and keep tests green") + 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", "hermes"]) 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.add_argument("--project-root", default=".") - process_cmd.add_argument("--adapter", choices=["codex", "claude"]) + process_cmd.add_argument("--adapter", choices=["codex", "claude", "hermes"]) 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", "hermes"]) review_candidates_cmd.add_argument("--format", choices=["text", "json"], default="text") review_candidate_cmd = sub.add_parser("review-candidate") @@ -269,11 +333,24 @@ def build_parser() -> argparse.ArgumentParser: context_cmd.add_argument("--limit", type=int, default=3) context_cmd.add_argument("--token-budget", type=int, default=240) context_cmd.add_argument("--format", choices=["text", "json", "hook-json"], default="text") + + hermes_context_cmd = sub.add_parser("render-hermes-context") + hermes_context_cmd.add_argument("--project-root", default=".") + hermes_context_cmd.add_argument("--prompt", required=True) + hermes_context_cmd.add_argument("--scope") + hermes_context_cmd.add_argument("--task-type") + hermes_context_cmd.add_argument("--file", action="append", dest="files", default=[]) + hermes_context_cmd.add_argument("--limit", type=int, default=3) + hermes_context_cmd.add_argument("--token-budget", type=int, default=240) + hermes_context_cmd.add_argument("--format", choices=["text", "json", "hook-json"], default="text") return parser def main() -> int: parser = build_parser() + raw_args = sys.argv[1:] + if raw_args and raw_args[0] in LEGACY_INSTALL_REPLACEMENTS: + exit_for_removed_install_command(parser, raw_args[0]) args = parser.parse_args() if args.command == "init": lifecycle = LearningLifecycle(Path(args.root)) @@ -318,29 +395,33 @@ def main() -> int: raise RuntimeError("uvicorn is not installed. Install with `pip install .[web]` or `uv sync --extra web`.") from exc uvicorn.run(app, host=args.host, port=args.port, log_level="info") return 0 - if args.command == "install-codex": - target = Path(args.target).expanduser().resolve() if args.target else (Path.home() if args.scope == "user" else Path.cwd().resolve()) - written = install_codex_adapter_with_scope(target, scope=args.scope) - for path in written: - print(path) - return 0 - if args.command == "install-claude": - target = Path(args.target).expanduser().resolve() if args.target else (Path.home() if args.scope == "user" else Path.cwd().resolve()) - written = install_claude_adapter_with_scope(target, scope=args.scope) - for path in written: - print(path) - return 0 if args.command == "bootstrap": - target = Path(args.target).resolve() adapters = [item.strip() for item in args.adapters.split(",") if item.strip()] written: list[Path] = [] + hermes_requested = "hermes" in adapters + codex_target = resolve_install_target(target=args.target, scope=args.codex_scope) + claude_target = resolve_install_target(target=args.target, scope=args.claude_scope) + hermes_target = resolve_install_target(target=args.target, scope=args.hermes_scope) + hermes_had_config = (hermes_target / ".hermes" / "config.yaml").exists() if hermes_requested else False if "codex" in adapters: - written.extend(install_codex_adapter_with_scope(target, scope=args.codex_scope)) + written.extend(install_codex_adapter_with_scope(codex_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)) + if hermes_requested: + written.extend(install_hermes_adapter_with_scope(hermes_target, scope=args.hermes_scope)) for path in dict.fromkeys(written): print(path) + emit_bootstrap_summary( + adapters=adapters, + used_default_target=args.target is None, + scopes={ + "codex": args.codex_scope, + "claude": args.claude_scope, + "hermes": args.hermes_scope, + }, + ) + if hermes_requested: + emit_hermes_install_guidance(hermes_target, scope=args.hermes_scope, had_config=hermes_had_config) return 0 if args.command == "promote-demo": lifecycle = LearningLifecycle(Path(args.root)) @@ -741,6 +822,167 @@ def count_by(field: str) -> dict[str, int]: cleanup_dir.cleanup() return 0 if result.returncode == 0 else result.returncode + if args.command == "qa-hermes-smoke": + target = Path(args.project_root).resolve() + cleanup_dir: tempfile.TemporaryDirectory[str] | None = None + if str(target) == str(Path('.').resolve()): + cleanup_dir = tempfile.TemporaryDirectory(prefix="agent-learner-hermes-smoke-") + target = Path(cleanup_dir.name).resolve() + had_config_before_install = (target / ".hermes" / "config.yaml").exists() + install_hermes_adapter(target) + hermes_home = target / ".hermes" + transcript_path = hermes_home / "sessions" / "session_hermes-smoke-session.jsonl" + transcript_path.parent.mkdir(parents=True, exist_ok=True) + transcript_path.write_text(json.dumps({"message": "Always keep Hermes learning rules short and reusable."}) + "\n", encoding="utf-8") + auto_script = hermes_home / "hooks" / "auto_session_learning.py" + prompt_script = hermes_home / "hooks" / "hermes_prompt_context.py" + lifecycle = LearningLifecycle(resolve_learning_root(target)) + lifecycle.promote( + LearningRule( + name="hermes-smoke-rule", + rule="Keep Hermes bootstrap changes covered by tests.", + why="Hermes adapter changes need regression coverage.", + scope="hermes adapter", + good_pattern="Update bootstrap code and tests together.", + avoid_pattern="Change Hermes wiring without tests.", + summary="Keep Hermes bootstrap changes covered by tests.", + triggers=["hermes", "bootstrap", "tests"], + task_types=["cli"], + priority="high", + confidence="high", + ) + ) + env = dict(os.environ) + env["PATH"] = f"{Path(sys.executable).parent}:{env.get('PATH', '')}" + src_path = str(Path(__file__).resolve().parents[2]) + env["PYTHONPATH"] = f"{src_path}:{env.get('PYTHONPATH', '')}" if env.get("PYTHONPATH") else src_path + env["HERMES_HOME"] = str(hermes_home) + auto_result = subprocess.run( + [sys.executable, str(auto_script)], + input=json.dumps( + { + "cwd": str(target), + "session_id": "hermes-smoke-session", + "extra": { + "summary": "Always keep Hermes learning rules short and reusable.", + }, + } + ), + capture_output=True, + text=True, + check=False, + env=env, + ) + prompt_result = subprocess.run( + [sys.executable, str(prompt_script)], + input=json.dumps( + { + "cwd": str(target), + "session_id": "hermes-smoke-session", + "extra": { + "user_message": args.prompt, + }, + } + ), + capture_output=True, + text=True, + check=False, + env=env, + ) + runtime: dict[str, object] = {"available": False} + hermes_cli = shutil.which("hermes") + if hermes_cli: + runtime["available"] = True + hooks_list = subprocess.run([hermes_cli, "hooks", "list"], capture_output=True, text=True, check=False, env=env, cwd=target) + hooks_doctor = subprocess.run([hermes_cli, "hooks", "doctor"], capture_output=True, text=True, check=False, env=env, cwd=target) + pre_payload_file = hermes_home / "pre_llm_payload.json" + pre_payload_file.write_text( + json.dumps({ + "session_id": "hermes-smoke-session", + "cwd": str(target), + "user_message": args.prompt, + }, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + end_payload_file = hermes_home / "on_session_end_payload.json" + end_payload_file.write_text( + json.dumps({ + "session_id": "hermes-smoke-session", + "cwd": str(target), + "summary": "Always keep Hermes learning rules short and reusable.", + }, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + hooks_test_pre = subprocess.run( + [hermes_cli, "hooks", "test", "pre_llm_call", "--payload-file", str(pre_payload_file)], + capture_output=True, + text=True, + check=False, + env=env, + cwd=target, + ) + hooks_test_end = subprocess.run( + [hermes_cli, "hooks", "test", "on_session_end", "--payload-file", str(end_payload_file)], + capture_output=True, + text=True, + check=False, + env=env, + cwd=target, + ) + runtime.update( + { + "hooks_list_returncode": hooks_list.returncode, + "hooks_list_stdout": hooks_list.stdout.strip(), + "hooks_doctor_returncode": hooks_doctor.returncode, + "hooks_doctor_stdout": hooks_doctor.stdout.strip(), + "hooks_test_pre_returncode": hooks_test_pre.returncode, + "hooks_test_pre_stdout": hooks_test_pre.stdout.strip(), + "hooks_test_end_returncode": hooks_test_end.returncode, + "hooks_test_end_stdout": hooks_test_end.stdout.strip(), + } + ) + event_files = sorted((target / ".agent-learner" / "events" / "hermes").glob("*.json")) + candidate_files = sorted((target / ".agent-learner" / "candidates" / "hermes").glob("*.md")) + rule_files = sorted((target / ".agent-learner" / "learning" / "approved").glob("*.md")) + prompt_payload = None + prompt_stdout = prompt_result.stdout.strip() + if prompt_stdout: + try: + prompt_payload = json.loads(prompt_stdout) + except json.JSONDecodeError: + prompt_payload = {"raw": prompt_stdout} + activation_hint = f"HERMES_HOME={hermes_home} hermes --accept-hooks" + merge_hint = f"Review and merge {hermes_home / 'config.agent-learner.yaml'} into the Hermes config you actually run if you already maintain one." + config_created = not had_config_before_install + config_preserved = had_config_before_install + print( + json.dumps( + { + "project_root": str(target), + "hermes_home": str(hermes_home), + "config_path": str(hermes_home / "config.yaml"), + "config_created": config_created, + "config_preserved": config_preserved, + "activation_hint": activation_hint, + "merge_hint": merge_hint, + "auto_script": str(auto_script), + "prompt_script": str(prompt_script), + "auto_returncode": auto_result.returncode, + "prompt_returncode": prompt_result.returncode, + "event_files": [str(path) for path in event_files], + "candidate_files": [str(path) for path in candidate_files], + "rule_files": [str(path) for path in rule_files], + "prompt_payload": prompt_payload, + "runtime": runtime, + }, + ensure_ascii=False, + indent=2, + ) + ) + if cleanup_dir is not None: + cleanup_dir.cleanup() + return 0 if auto_result.returncode == 0 and prompt_result.returncode == 0 else 1 + if args.command == "qa-codex-smoke": target = Path(args.project_root).resolve() cleanup_dir: tempfile.TemporaryDirectory[str] | None = None @@ -849,6 +1091,27 @@ def count_by(field: str) -> dict[str, int]: if text: print(text) return 0 + if args.command == "render-hermes-context": + project_root = Path(args.project_root).resolve() + text = render_codex_learning_context( + resolve_learning_root(project_root), + args.prompt, + scope=args.scope, + task_type=args.task_type, + file_paths=args.files, + limit=args.limit, + token_budget=args.token_budget, + ) + if args.format == "hook-json": + if text: + print(json.dumps({"context": text}, ensure_ascii=False)) + return 0 + if args.format == "json": + print(json.dumps({"additional_context": text}, ensure_ascii=False, indent=2)) + return 0 + if text: + print(text) + return 0 parser.print_help() return 1 diff --git a/test/wrapper.test.cjs b/test/wrapper.test.cjs index 184be61..8864d8f 100644 --- a/test/wrapper.test.cjs +++ b/test/wrapper.test.cjs @@ -12,6 +12,7 @@ const { publishedCoreProbe, runWrapperUpdate, laneDoctorChecks, + printHelp, } = require('../lib/wrapper.cjs'); test('parseArgs handles codex install', () => { @@ -36,14 +37,27 @@ test('parseArgs handles codex install user scope', () => { }); }); -test('parseArgs handles version and doctor', () => { +test('parseArgs handles version, doctor, and bootstrap', () => { assert.deepEqual(parseArgs(['version']), { type: 'version' }); assert.deepEqual(parseArgs(['doctor', '--json']), { type: 'doctor', json: true }); - assert.deepEqual(parseArgs(['install-codex', '--target', '/tmp/repo']), { type: 'lane', lane: 'codex', action: 'install', target: '/tmp/repo', scope: null, json: false }); + assert.deepEqual(parseArgs(['bootstrap', '--adapters', 'codex']), { type: 'core', coreArgs: ['bootstrap', '--adapters', 'codex'] }); assert.deepEqual(parseArgs(['rebuild-index', '--project-root', '/tmp/repo', '--scope', 'project', '--format', 'json']), { type: 'core', coreArgs: ['rebuild-index', '--project-root', '/tmp/repo', '--scope', 'project', '--format', 'json'] }); assert.deepEqual(parseArgs(['update']), { type: 'update' }); }); +test('parseArgs rejects removed top-level install aliases', () => { + assert.deepEqual(parseArgs(['install-codex', '--target', '/tmp/repo']), { + type: 'removed-install', + command: 'install-codex', + replacement: 'agent-learner bootstrap --adapters codex', + }); + assert.deepEqual(parseArgs(['install-claude', '--target', '/tmp/repo']), { + type: 'removed-install', + command: 'install-claude', + replacement: 'agent-learner bootstrap --adapters claude', + }); +}); + test('parseArgs handles dashboard flags', () => { assert.deepEqual(parseArgs(['dashboard', '--project-root', '/tmp/repo', '--build', '--open', '--port', '8877']), { type: 'dashboard', @@ -78,7 +92,7 @@ test('buildExecutionPlan uses local uv run inside repo checkout', () => { const plan = buildExecutionPlan({ type: 'lane', lane: 'codex', action: 'install', target: '/tmp/repo', json: false }, packageRoot, '/tmp/repo'); assert.equal(plan.mode, 'local'); assert.equal(plan.command, 'uv'); - assert.deepEqual(plan.args, ['run', 'agent-learner', 'install-codex', '--target', '/tmp/repo', '--scope', 'user']); + assert.deepEqual(plan.args, ['run', 'agent-learner', 'bootstrap', '--adapters', 'codex', '--target', '/tmp/repo', '--codex-scope', 'user']); }); test('buildExecutionPlan preserves user scope for codex install', () => { @@ -86,7 +100,7 @@ test('buildExecutionPlan preserves user scope for codex install', () => { const plan = buildExecutionPlan({ type: 'lane', lane: 'codex', action: 'install', target: null, scope: 'user', json: false }, packageRoot, '/tmp/repo'); assert.equal(plan.mode, 'local'); assert.equal(plan.command, 'uv'); - assert.deepEqual(plan.args, ['run', 'agent-learner', 'install-codex', '--scope', 'user']); + assert.deepEqual(plan.args, ['run', 'agent-learner', 'bootstrap', '--adapters', 'codex', '--codex-scope', 'user']); }); test('buildExecutionPlan falls back to uvx without local core', () => { @@ -102,7 +116,7 @@ test('buildExecutionPlan refreshes published core for codex install', () => { const plan = buildExecutionPlan({ type: 'lane', lane: 'codex', action: 'install', target: null, scope: 'user', json: false }, fakeRoot, '/tmp/repo'); assert.equal(plan.mode, 'published'); assert.equal(plan.command, 'uvx'); - assert.deepEqual(plan.args, ['--refresh', '--from', 'agent-learner[web]', 'agent-learner', 'install-codex', '--scope', 'user']); + assert.deepEqual(plan.args, ['--refresh', '--from', 'agent-learner[web]', 'agent-learner', 'bootstrap', '--adapters', 'codex', '--codex-scope', 'user']); }); test('buildExecutionPlan honors uvx index override', () => { @@ -233,17 +247,37 @@ test('wrapper version comes from package json', () => { }); -test('completionScript exposes update alias and direct install aliases', () => { +test('completionScript exposes bootstrap and hides removed install aliases', () => { const { completionScript } = require('../lib/wrapper.cjs'); const bash = completionScript('bash'); - assert.match(bash, /install-codex/); + assert.doesNotMatch(bash, /install-codex/); + assert.match(bash, /bootstrap/); assert.match(bash, /rebuild-index/); assert.match(bash, /update/); const zsh = completionScript('zsh'); - assert.match(zsh, /install-codex/); + assert.doesNotMatch(zsh, /install-codex/); + assert.match(zsh, /bootstrap/); assert.match(zsh, /update/); }); +test('printHelp advertises bootstrap as the install path', () => { + const packageRoot = path.resolve(__dirname, '..'); + const calls = []; + const originalLog = console.log; + console.log = (message) => { + calls.push(String(message)); + }; + try { + printHelp(packageRoot); + } finally { + console.log = originalLog; + } + const output = calls.join('\n'); + assert.match(output, /agent-learner bootstrap/); + assert.doesNotMatch(output, /install-codex/); + assert.doesNotMatch(output, /install-claude/); +}); + test('runWrapperUpdate shells out to npm global install', () => { const calls = []; const fakeRunner = (tool, args) => { diff --git a/tests/test_cli_bootstrap.py b/tests/test_cli_bootstrap.py index 9b36f2f..78e4fce 100644 --- a/tests/test_cli_bootstrap.py +++ b/tests/test_cli_bootstrap.py @@ -1,10 +1,12 @@ import json from pathlib import Path +import pytest + from agent_learner.core.doctor import collect_dashboard_doctor, ensure_frontend_dist, format_doctor_text from agent_learner.core.dashboard import build_dashboard_summary, merge_rules from agent_learner.core.fastapi_app import app_root_dir, frontend_dist_dir, frontend_src_dir, frontend_dist_is_valid -from agent_learner.cli.main import main as cli_main +from agent_learner.cli.main import build_parser, main as cli_main from agent_learner.core.lifecycle import LearningLifecycle from agent_learner.core.models import LearningRule from agent_learner.core.webapp import apply_web_action, render_dashboard_app_html, run_dashboard_server @@ -13,7 +15,7 @@ def test_bootstrap_codex_only(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr( "sys.argv", - ["agent-learner", "bootstrap", "--target", str(tmp_path), "--adapters", "codex"], + ["agent-learner", "bootstrap", "--target", str(tmp_path), "--adapters", "codex", "--codex-scope", "project"], ) assert cli_main() == 0 assert (tmp_path / ".codex" / "hooks.json").exists() @@ -21,26 +23,33 @@ def test_bootstrap_codex_only(monkeypatch, tmp_path: Path) -> None: assert not (tmp_path / ".claude").exists() -def test_install_codex_user_scope_writes_to_home(monkeypatch, tmp_path: Path) -> None: +def test_bootstrap_codex_user_scope_writes_to_home(monkeypatch, tmp_path: Path) -> None: home_root = tmp_path / "home" monkeypatch.setattr( "sys.argv", - ["agent-learner", "install-codex", "--scope", "user", "--target", str(home_root)], + ["agent-learner", "bootstrap", "--adapters", "codex", "--codex-scope", "user", "--target", str(home_root)], ) assert cli_main() == 0 assert (home_root / ".codex" / "hooks.json").exists() assert not (home_root / ".agent-learner" / "learning").exists() -def test_install_codex_defaults_to_user_scope(monkeypatch, tmp_path: Path) -> None: - home_root = tmp_path / "home-default" - monkeypatch.setattr( - "sys.argv", - ["agent-learner", "install-codex", "--target", str(home_root)], - ) - assert cli_main() == 0 - assert (home_root / ".codex" / "hooks.json").exists() - assert not (home_root / ".agent-learner" / "learning").exists() +@pytest.mark.parametrize( + ("command", "replacement"), + [ + ("install-codex", "bootstrap --adapters codex"), + ("install-claude", "bootstrap --adapters claude"), + ("install-hermes", "bootstrap --adapters hermes"), + ], +) +def test_removed_install_commands_point_to_bootstrap(monkeypatch, capsys, command: str, replacement: str) -> None: + monkeypatch.setattr("sys.argv", ["agent-learner", command]) + with pytest.raises(SystemExit) as exc: + cli_main() + assert exc.value.code == 2 + stderr = capsys.readouterr().err + assert f"`{command}` was removed" in stderr + assert replacement in stderr def test_doctor_command_reports_status(monkeypatch, tmp_path: Path, capsys) -> None: @@ -70,14 +79,115 @@ def test_bootstrap_claude_only(monkeypatch, tmp_path: Path) -> None: assert not (tmp_path / ".codex").exists() -def test_bootstrap_both(monkeypatch, tmp_path: Path) -> None: +def test_bootstrap_hermes_project_scope_creates_project_assets(monkeypatch, tmp_path: Path, capsys) -> None: monkeypatch.setattr( "sys.argv", - ["agent-learner", "bootstrap", "--target", str(tmp_path), "--claude-scope", "project"], + ["agent-learner", "bootstrap", "--adapters", "hermes", "--hermes-scope", "project", "--target", str(tmp_path)], ) assert cli_main() == 0 - assert (tmp_path / ".codex" / "hooks.json").exists() - assert (tmp_path / ".claude" / "settings.json").exists() + assert (tmp_path / ".agent-learner" / "events" / "hermes").exists() + assert (tmp_path / ".hermes" / "hooks" / "auto_session_learning.py").exists() + assert (tmp_path / ".hermes" / "hooks" / "hermes_prompt_context.py").exists() + assert (tmp_path / ".hermes" / "config.yaml").exists() + assert (tmp_path / ".hermes" / "config.agent-learner.yaml").exists() + assert not (tmp_path / ".codex").exists() + assert not (tmp_path / ".claude").exists() + stderr = capsys.readouterr().err + assert "Hermes adapter installed in project-local opt-in mode" in stderr + assert "Safest activation:" in stderr + assert "HERMES_HOME=" in stderr + assert "must also have model/auth configured" in stderr + + +def test_bootstrap_hermes_project_scope_preserves_existing_config_and_prints_merge_guidance(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") + monkeypatch.setattr( + "sys.argv", + ["agent-learner", "bootstrap", "--adapters", "hermes", "--hermes-scope", "project", "--target", str(tmp_path)], + ) + assert cli_main() == 0 + assert config_path.read_text(encoding="utf-8") == "model:\n provider: openai-codex\n" + stderr = capsys.readouterr().err + assert "Existing Hermes config preserved" in stderr + assert "config.agent-learner.yaml" in stderr + + +def test_bootstrap_defaults_to_all_three_user_scope(monkeypatch, tmp_path: Path, capsys) -> None: + home_root = tmp_path / "home-bootstrap" + monkeypatch.setattr(Path, "home", staticmethod(lambda: home_root)) + monkeypatch.setattr( + "sys.argv", + ["agent-learner", "bootstrap"], + ) + assert cli_main() == 0 + assert (home_root / ".codex" / "hooks.json").exists() + assert (home_root / ".claude" / "settings.json").exists() + assert (home_root / ".hermes" / "config.yaml").exists() + assert not (home_root / ".agent-learner" / "events").exists() + stderr = capsys.readouterr().err + assert "Bootstrap installed adapters: codex, claude, hermes" in stderr + assert "Default bootstrap keeps everything in user scope unless you opt into project scope." in stderr + + +def test_bootstrap_hermes_only_defaults_to_user_scope(monkeypatch, tmp_path: Path, capsys) -> None: + home_root = tmp_path / "home-hermes-bootstrap" + monkeypatch.setattr(Path, "home", staticmethod(lambda: home_root)) + monkeypatch.setattr( + "sys.argv", + ["agent-learner", "bootstrap", "--adapters", "hermes"], + ) + assert cli_main() == 0 + assert (home_root / ".hermes" / "hooks" / "auto_session_learning.py").exists() + assert not (home_root / ".agent-learner").exists() + assert not (home_root / ".codex").exists() + assert not (home_root / ".claude").exists() + stderr = capsys.readouterr().err + assert "Hermes user-scope hooks installed" in stderr + 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: + 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") + 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" + stderr = capsys.readouterr().err + assert "Existing Hermes config preserved" in stderr + assert "config.agent-learner.yaml" in stderr + + +def test_bootstrap_help_emphasizes_one_command_default_and_labels_advanced_flags(capsys) -> None: + parser = build_parser() + try: + parser.parse_args(["bootstrap", "--help"]) + except SystemExit as exc: + assert exc.code == 0 + help_text = capsys.readouterr().out + assert "One-command setup:" in help_text + assert "installs codex, claude, and hermes in user scope by" in help_text + assert "default" in help_text + assert "advanced" in help_text.lower() + assert "--target TARGET" in help_text + assert "--hermes-scope {project,user}" in help_text + + +def test_removed_install_commands_are_not_in_parser(capsys) -> None: + parser = build_parser() + with pytest.raises(SystemExit) as exc: + parser.parse_args(["install-hermes"]) + assert exc.value.code == 2 + stderr = capsys.readouterr().err + assert "invalid choice" in stderr + assert "install-hermes" in stderr def test_bootstrap_migrates_legacy_codex_learning_assets(monkeypatch, tmp_path: Path) -> None: @@ -89,7 +199,7 @@ def test_bootstrap_migrates_legacy_codex_learning_assets(monkeypatch, tmp_path: ) monkeypatch.setattr( "sys.argv", - ["agent-learner", "bootstrap", "--target", str(tmp_path), "--adapters", "codex"], + ["agent-learner", "bootstrap", "--target", str(tmp_path), "--adapters", "codex", "--codex-scope", "project"], ) assert cli_main() == 0 assert (tmp_path / ".agent-learner" / "learning" / "approved" / "legacy-rule.md").exists() @@ -133,6 +243,44 @@ def test_render_codex_context_command_outputs_hook_json(monkeypatch, tmp_path: P assert "active_learning" in payload["hookSpecificOutput"]["additionalContext"] +def test_render_hermes_context_command_outputs_json(monkeypatch, tmp_path: Path, capsys) -> None: + monkeypatch.setenv("AGENT_LEARNER_HOME", str(tmp_path / "home-learning")) + lifecycle = LearningLifecycle(tmp_path / ".agent-learner" / "learning") + lifecycle.promote( + LearningRule( + name="hermes-tests-updated", + rule="Update Hermes tests whenever bootstrap behavior changes.", + why="Hermes bootstrap wiring should stay regression-tested.", + scope="hermes adapter", + good_pattern="Edit Hermes bootstrap code and tests together.", + avoid_pattern="Change Hermes wiring without tests.", + summary="Keep Hermes bootstrap changes covered by tests.", + triggers=["hermes", "bootstrap", "tests"], + task_types=["cli"], + priority="high", + confidence="high", + ) + ) + monkeypatch.setattr( + "sys.argv", + [ + "agent-learner", + "render-hermes-context", + "--project-root", + str(tmp_path), + "--prompt", + "update hermes bootstrap wiring and tests", + "--format", + "json", + ], + ) + assert cli_main() == 0 + payload = json.loads(capsys.readouterr().out) + assert "additional_context" in payload + assert "active_learning" in payload["additional_context"] + assert "hermes-tests-updated" in payload["additional_context"] + + def test_retrieve_command_outputs_ranked_rules(monkeypatch, tmp_path: Path, capsys) -> None: monkeypatch.setenv("AGENT_LEARNER_HOME", str(tmp_path / "home-learning")) lifecycle = LearningLifecycle(tmp_path / ".agent-learner" / "learning") @@ -219,6 +367,31 @@ def test_capture_event_command_writes_normalized_event(monkeypatch, tmp_path: Pa assert payload["payload"]["prompt"] == "hello" +def test_capture_event_command_writes_hermes_session_end(monkeypatch, tmp_path: Path, capsys) -> None: + monkeypatch.setattr( + "sys.argv", + [ + "agent-learner", + "capture-event", + "--project-root", + str(tmp_path), + "--adapter", + "hermes", + "--event-name", + "session_end", + "--session-id", + "session-hermes-1", + ], + ) + monkeypatch.setattr("sys.stdin", __import__("io").StringIO('{"summary":"Always keep Hermes rules concise."}')) + assert cli_main() == 0 + out_path = Path(capsys.readouterr().out.strip()) + payload = json.loads(out_path.read_text(encoding="utf-8")) + assert payload["adapter"] == "hermes" + assert payload["event_name"] == "session_end" + assert payload["payload"]["summary"] == "Always keep Hermes rules concise." + + def test_process_events_command_outputs_candidate_json(monkeypatch, tmp_path: Path, capsys) -> None: transcript = tmp_path / "session.jsonl" transcript.write_text(json.dumps({"message": "Always keep learned rules concise."}) + "\n", encoding="utf-8") @@ -243,6 +416,31 @@ def test_process_events_command_outputs_candidate_json(monkeypatch, tmp_path: Pa assert payload[0]["status"] == "rule_promoted" +def test_process_events_command_outputs_hermes_candidate_json(monkeypatch, tmp_path: Path, capsys) -> None: + transcript = tmp_path / "session.jsonl" + transcript.write_text(json.dumps({"message": "Always keep Hermes learning rules concise and reusable."}) + "\n", encoding="utf-8") + event_dir = tmp_path / ".agent-learner" / "events" / "hermes" + event_dir.mkdir(parents=True, exist_ok=True) + event_path = event_dir / "session_end-h1.json" + event_path.write_text(json.dumps({ + "adapter": "hermes", + "event_name": "session_end", + "cwd": str(tmp_path), + "captured_at": "2026-04-20T00:00:00Z", + "session_id": "h1", + "transcript_path": str(transcript), + "payload": {"message": "done"} + })) + monkeypatch.setattr( + "sys.argv", + ["agent-learner", "process-events", "--project-root", str(tmp_path), "--adapter", "hermes", "--format", "json"], + ) + assert cli_main() == 0 + payload = json.loads(capsys.readouterr().out) + assert payload[0]["status"] == "rule_promoted" + assert payload[0]["source_adapter"] == "hermes" + + def test_review_candidates_and_approve_candidate_commands(monkeypatch, tmp_path: Path, capsys) -> None: transcript = tmp_path / "session.jsonl" transcript.write_text(json.dumps({"message": "Always update tests whenever behavior changes in services."}) + "\n", encoding="utf-8") @@ -882,6 +1080,29 @@ def test_qa_claude_smoke_creates_event_and_candidate(monkeypatch, capsys) -> Non assert payload["candidate_files"] +def test_qa_hermes_smoke_creates_event_candidate_and_prompt_context(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.argv", ["agent-learner", "qa-hermes-smoke"]) + assert cli_main() == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["auto_returncode"] == 0 + assert payload["prompt_returncode"] == 0 + assert payload["event_files"] + assert payload["candidate_files"] + assert payload["rule_files"] + assert payload["config_path"].endswith("/.hermes/config.yaml") + assert payload["config_created"] is True + assert payload["config_preserved"] is False + assert payload["activation_hint"].startswith("HERMES_HOME=") + assert payload["activation_hint"].endswith(" hermes --accept-hooks") + assert "config.agent-learner.yaml" in payload["merge_hint"] + assert payload["prompt_payload"]["context"] + assert "active_learning" in payload["prompt_payload"]["context"] + if payload["runtime"]["available"]: + assert payload["runtime"]["hooks_list_returncode"] == 0 + assert payload["runtime"]["hooks_test_pre_returncode"] == 0 + assert payload["runtime"]["hooks_test_end_returncode"] == 0 + + def test_detect_context_set_model_and_sweep_commands(monkeypatch, tmp_path: Path, capsys) -> None: (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8") monkeypatch.setattr("sys.argv", ["agent-learner", "set-model", "--project-root", str(tmp_path), "--model", "claude-opus-4-7"]) From 6a96770414feb56597278d05c708e296b9ff9ff5 Mon Sep 17 00:00:00 2001 From: cafitac Date: Mon, 27 Apr 2026 23:23:31 +0900 Subject: [PATCH 2/2] fix: restore hermes adapter support for bootstrap flow --- src/agent_learner/adapters/__init__.py | 9 +- src/agent_learner/adapters/hermes.py | 325 +++++++++++++++++++++++++ src/agent_learner/core/pipeline.py | 2 + tests/test_installers.py | 31 ++- tests/test_pipeline.py | 27 ++ 5 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 src/agent_learner/adapters/hermes.py diff --git a/src/agent_learner/adapters/__init__.py b/src/agent_learner/adapters/__init__.py index d18285e..ed58889 100644 --- a/src/agent_learner/adapters/__init__.py +++ b/src/agent_learner/adapters/__init__.py @@ -1,4 +1,11 @@ from .claude import install_claude_adapter, install_claude_adapter_with_scope from .codex import install_codex_adapter +from .hermes import install_hermes_adapter, install_hermes_adapter_with_scope -__all__ = ["install_codex_adapter", "install_claude_adapter", "install_claude_adapter_with_scope"] +__all__ = [ + "install_codex_adapter", + "install_claude_adapter", + "install_claude_adapter_with_scope", + "install_hermes_adapter", + "install_hermes_adapter_with_scope", +] diff --git a/src/agent_learner/adapters/hermes.py b/src/agent_learner/adapters/hermes.py new file mode 100644 index 0000000..53426a5 --- /dev/null +++ b/src/agent_learner/adapters/hermes.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import shlex +import sys +from pathlib import Path + +from .common import append_lines_if_missing, ensure_dir, write_text + +AUTO_SESSION_LEARNING = """#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import subprocess +import sys +from datetime import datetime +from pathlib import Path + + +def read_json() -> dict: + try: + if sys.stdin.isatty(): + return {} + except Exception: + return {} + raw = sys.stdin.read() + if not raw.strip(): + return {} + try: + return json.loads(raw) + except Exception: + return {} + + +def run_shared_cli(project_root: Path, argv: list[str], payload: dict | None = None) -> None: + if importlib.util.find_spec("agent_learner") is not None: + base = [sys.executable, "-m", "agent_learner.cli.main"] + else: + cli = shutil.which("agent-learner") + base = [cli, "core"] if cli else [sys.executable, "-m", "agent_learner.cli.main"] + try: + subprocess.run(base + argv, input=json.dumps(payload or {}), capture_output=True, text=True, check=False, timeout=30) + except (subprocess.TimeoutExpired, Exception): + return + + +def detect_project_root(cwd: Path) -> Path: + current = cwd.resolve() + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=str(current), + capture_output=True, + text=True, + check=False, + ) + root = (result.stdout or "").strip() + if result.returncode == 0 and root: + return Path(root).resolve() + except Exception: + pass + for _ in range(20): + if any((current / marker).exists() for marker in ("pyproject.toml", "package.json", "go.mod", "Cargo.toml", ".git")): + return current + if current.parent == current: + break + current = current.parent + return cwd.resolve() + + +def detect_hermes_home(cwd: Path) -> Path: + env_home = os.environ.get("HERMES_HOME") + if env_home: + return Path(env_home).expanduser().resolve() + project_home = cwd / ".hermes" + if project_home.exists(): + return project_home.resolve() + return (Path.home() / ".hermes").resolve() + + +def resolve_transcript_path(payload: dict, *, cwd: Path, session_id: str) -> Path | None: + extra = payload.get("extra") if isinstance(payload.get("extra"), dict) else {} + nested_extra = extra.get("extra") if isinstance(extra.get("extra"), dict) else {} + explicit = ( + payload.get("transcript_path") + or payload.get("transcriptPath") + or extra.get("transcript_path") + or extra.get("transcriptPath") + or nested_extra.get("transcript_path") + or nested_extra.get("transcriptPath") + ) + if isinstance(explicit, str) and explicit.strip(): + candidate = Path(explicit).expanduser() + if not candidate.is_absolute(): + candidate = cwd / candidate + if candidate.exists(): + return candidate.resolve() + + hermes_home = detect_hermes_home(cwd) + sessions_dir = hermes_home / "sessions" + for name in ( + f"{session_id}.json", + f"{session_id}.jsonl", + f"session_{session_id}.json", + f"session_{session_id}.jsonl", + ): + candidate = sessions_dir / name + if candidate.exists(): + return candidate.resolve() + return None + + +def emit_shared_event(project_root: Path, payload: dict, session_id: str, transcript_path: Path | None) -> None: + argv = [ + "capture-event", + "--project-root", + str(project_root), + "--adapter", + "hermes", + "--event-name", + "session_end", + "--session-id", + session_id, + ] + if transcript_path is not None: + argv.extend(["--transcript-path", str(transcript_path)]) + run_shared_cli(project_root, argv, payload) + run_shared_cli(project_root, ["process-events", "--project-root", str(project_root), "--adapter", "hermes", "--limit", "1"], None) + + +def main() -> int: + payload = read_json() + cwd = Path(payload.get("cwd") or os.getcwd()).resolve() + project_root = detect_project_root(cwd) + session_id = payload.get("session_id") or datetime.now().strftime("%Y%m%d-%H%M%S") + transcript_path = resolve_transcript_path(payload, cwd=cwd, session_id=session_id) + emit_shared_event(project_root, payload, session_id, transcript_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +""" + +PROMPT_CONTEXT = """#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def read_json() -> dict: + try: + if sys.stdin.isatty(): + return {} + except Exception: + return {} + raw = sys.stdin.read() + if not raw.strip(): + return {} + try: + return json.loads(raw) + except Exception: + return {} + + +def detect_project_root(cwd: Path) -> Path: + current = cwd.resolve() + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=str(current), + capture_output=True, + text=True, + check=False, + ) + root = (result.stdout or "").strip() + if result.returncode == 0 and root: + return Path(root).resolve() + except Exception: + pass + for _ in range(20): + if any((current / marker).exists() for marker in ("pyproject.toml", "package.json", "go.mod", "Cargo.toml", ".git")): + return current + if current.parent == current: + break + current = current.parent + return cwd.resolve() + + +def extract_prompt(payload: dict) -> str: + extra = payload.get("extra") if isinstance(payload.get("extra"), dict) else {} + nested_extra = extra.get("extra") if isinstance(extra.get("extra"), dict) else {} + prompt = ( + extra.get("user_message") + or nested_extra.get("user_message") + or payload.get("prompt") + or payload.get("user_prompt") + or payload.get("userPrompt") + or payload.get("user_message") + or nested_extra.get("prompt") + or "" + ) + return prompt.strip() if isinstance(prompt, str) else "" + + +def main() -> int: + payload = read_json() + prompt = extract_prompt(payload) + if not prompt: + return 0 + + project_root = detect_project_root(Path(payload.get("cwd") or os.getcwd()).resolve()) + if importlib.util.find_spec("agent_learner") is not None: + argv = [sys.executable, "-m", "agent_learner.cli.main", "render-hermes-context", "--project-root", str(project_root), "--prompt", prompt, "--format", "hook-json"] + else: + cli = shutil.which("agent-learner") + argv = [cli, "core", "render-hermes-context", "--project-root", str(project_root), "--prompt", prompt, "--format", "hook-json"] if cli else [sys.executable, "-m", "agent_learner.cli.main", "render-hermes-context", "--project-root", str(project_root), "--prompt", prompt, "--format", "hook-json"] + + try: + result = subprocess.run(argv, capture_output=True, text=True, check=False, timeout=30) + except (subprocess.TimeoutExpired, Exception): + return 0 + if result.returncode != 0: + return 0 + output = result.stdout.strip() + if output: + sys.stdout.write(output + "\\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +""" + +ROOT_GITIGNORE_LINES = [ + ".agent-learner/events/", + ".agent-learner/candidates/", + ".agent-learner/history/", + ".agent-learner/state/", +] + +CONFIG_SNIPPET_HEADER = "# agent-learner hermes hooks snippet\n" +ACTIVATION_NOTES = """# Agent Learner + Hermes + +This directory contains a project-local Hermes home for agent-learner hooks. + +Safe default: +- Hermes will NOT read this automatically unless you opt in. +- To use the project-local hooks without affecting your default Hermes setup: + + HERMES_HOME=.hermes hermes --accept-hooks + +- The project-local HERMES_HOME must also have model/auth configured. +- If it does not, merge the hook entries from config.agent-learner.yaml into the Hermes config you already use. + +If you already maintain your own Hermes config, merge the hook entries from +config.agent-learner.yaml into your chosen config.yaml after review. +""" + + +def install_hermes_adapter(target_root: Path) -> list[Path]: + return install_hermes_adapter_with_scope(target_root, scope="project") + + +def _command_for_script(script_path: Path, *, scope: str) -> str: + python_cmd = shlex.quote(sys.executable) + if scope == "user": + return f"{python_cmd} {shlex.quote(str(script_path))}" + return f"{python_cmd} {shlex.quote(f'./.hermes/hooks/{script_path.name}') }" + + +def _render_config_yaml(*, prompt_command: str, auto_command: str) -> str: + return ( + "hooks:\n" + " pre_llm_call:\n" + f" - command: {prompt_command!r}\n" + " timeout: 15\n" + " on_session_end:\n" + f" - command: {auto_command!r}\n" + " timeout: 15\n" + "hooks_auto_accept: false\n" + ) + + +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) + config_text = _render_config_yaml(prompt_command=prompt_command, auto_command=auto_command) + config_path = hermes_root / "config.yaml" + snippet_path = hermes_root / "config.agent-learner.yaml" + written: list[Path] = [] + + if not config_path.exists(): + written.append(write_text(config_path, config_text)) + 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 + + +def install_hermes_adapter_with_scope(target_root: Path, *, scope: str = "project") -> list[Path]: + if scope not in {"project", "user"}: + raise ValueError(f"unsupported hermes install scope: {scope}") + + written: list[Path] = [] + hermes_root = ensure_dir(target_root / ".hermes") + hooks_root = ensure_dir(hermes_root / "hooks") + auto_script = hooks_root / "auto_session_learning.py" + prompt_script = hooks_root / "hermes_prompt_context.py" + + if scope == "project": + ensure_dir(target_root / ".agent-learner" / "events" / "hermes") + written.append(append_lines_if_missing(target_root / ".gitignore", ROOT_GITIGNORE_LINES)) + + written.append(write_text(auto_script, AUTO_SESSION_LEARNING)) + written.append(write_text(prompt_script, PROMPT_CONTEXT)) + written.extend(_write_config_files(hermes_root, scope=scope, prompt_script=prompt_script, auto_script=auto_script)) + return written diff --git a/src/agent_learner/core/pipeline.py b/src/agent_learner/core/pipeline.py index 4e7059c..3bbb86e 100644 --- a/src/agent_learner/core/pipeline.py +++ b/src/agent_learner/core/pipeline.py @@ -68,6 +68,7 @@ class LearningCandidate: class ProcessedEventResult: event_path: str status: str + source_adapter: str | None = None candidate_path: str | None = None rule_path: str | None = None reason: str | None = None @@ -322,6 +323,7 @@ def process_unprocessed_events(project_root: Path, adapter: str | None = None, l ProcessedEventResult( event_path=str(event_path), status=status, + source_adapter=event.adapter, candidate_path=str(candidate_path) if candidate_path else None, rule_path=str(rule_path) if rule_path else None, reason=None if candidate is not None else "no durable rule-like signal found", diff --git a/tests/test_installers.py b/tests/test_installers.py index 3475a69..7c2e0fb 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -1,7 +1,7 @@ import json from pathlib import Path -from agent_learner.adapters import install_claude_adapter, install_codex_adapter +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.core.storage import read_project_registry, register_project, resolve_learning_root, should_register_project, storage_migration_marker_path @@ -79,6 +79,35 @@ def test_install_claude_adapter_user_scope_creates_user_assets_only(tmp_path: Pa assert settings["hooks"]["SessionEnd"][0]["hooks"][0]["timeout"] == 30 +def test_install_hermes_adapter_creates_expected_assets(tmp_path: Path) -> None: + written = install_hermes_adapter(tmp_path) + hermes_root = tmp_path / ".hermes" + assert (tmp_path / ".agent-learner" / "events" / "hermes").exists() + assert (hermes_root / "hooks" / "auto_session_learning.py").exists() + assert (hermes_root / "hooks" / "hermes_prompt_context.py").exists() + assert (hermes_root / "config.yaml").exists() + assert (hermes_root / "config.agent-learner.yaml").exists() + assert (hermes_root / "AGENT_LEARNER_README.md").exists() + assert not (tmp_path / ".codex").exists() + assert not (tmp_path / ".claude").exists() + assert written + + config_text = (hermes_root / "config.yaml").read_text(encoding="utf-8") + auto_script = (hermes_root / "hooks" / "auto_session_learning.py").read_text(encoding="utf-8") + prompt_script = (hermes_root / "hooks" / "hermes_prompt_context.py").read_text(encoding="utf-8") + assert "pre_llm_call" in config_text + assert "on_session_end" in config_text + assert "./.hermes/hooks/hermes_prompt_context.py" in config_text + assert "./.hermes/hooks/auto_session_learning.py" in config_text + assert '--adapter", "hermes"' in auto_script + assert '--event-name' in auto_script + assert '"process-events", "--project-root", str(project_root), "--adapter", "hermes", "--limit", "1"' in auto_script + assert "session_{session_id}.json" in auto_script + assert 'extra.get("user_message")' in prompt_script + assert 'render-hermes-context' in prompt_script + assert '--adapter", "hermes"' not in prompt_script + + def test_installers_are_independent(tmp_path: Path) -> None: install_codex_adapter(tmp_path) assert not (tmp_path / ".claude").exists() diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 5926b03..8e4d538 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -64,6 +64,33 @@ def test_process_events_extracts_candidate_from_transcript_and_marks_processed(t assert second == [] +def test_process_events_extracts_hermes_candidate_from_transcript(tmp_path: Path) -> None: + transcript = tmp_path / "session.jsonl" + transcript.write_text(json.dumps({"message": "Always keep Hermes learning rules short and reusable."}) + "\n", encoding="utf-8") + event_path = write_learning_event( + tmp_path, + build_learning_event( + adapter="hermes", + event_name="session_end", + cwd=str(tmp_path), + session_id="hermes-123", + transcript_path=str(transcript), + payload={"message": "session ended"}, + ), + ) + + results = process_unprocessed_events(tmp_path, adapter="hermes") + assert len(results) == 1 + assert results[0].status == "rule_promoted" + assert results[0].source_adapter == "hermes" + assert results[0].candidate_path is not None + assert results[0].rule_path is not None + candidate_record = load_candidate_record(Path(results[0].candidate_path or "")) + assert candidate_record.status == "auto_applied" + assert candidate_record.candidate.review_required is False + assert is_processed(tmp_path, event_path) + + def test_extract_candidate_returns_none_without_rule_signal(tmp_path: Path) -> None: event_path = write_learning_event( tmp_path,