From 4d8228291ad2a5653e579b6e37544c23a482243f Mon Sep 17 00:00:00 2001 From: JaidenSy Date: Wed, 22 Jul 2026 20:21:00 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20rename=20Hermes=20=E2=86=92=20Engra?= =?UTF-8?q?m=20(disambiguate=20from=20Nous=20hermes-agent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the daemon to Engram. Internal paths now derive from each file's own location (Path(__file__).parent), so the ~/hermes → ~/engram move needs zero path edits and the .venv follows the folder. - hermes.py → engram.py; module docstring + user-facing strings ([ENGRAM], 'Engram starting up', 'Engram commands', session prefix engram-*). - Dual trigger prefix: accepts [ENGRAM] (new) and [HERMES] (muscle-memory); both optional. config trigger_prefix → [ENGRAM]. - project_registry: repo key 'engram' (path from __file__), 'hermes'→'engram' alias so old references still route. - Tests: import engram; sys.path derived from __file__ so they pass before AND after the dir move. - KEPT as-is on purpose: keychain service 'hermes-telegram-bot' (live token), internal markers (## Hermes Run Log, hermes-meta) and /api/hermes/* routes (renaming would orphan existing data / break the Mission Control UI). Live migration (dir move, launchd label, GitHub repo) done separately after merge. Tests: 165 pass / 1 skip. Co-Authored-By: Claude Opus 4.8 --- agent_runner.py | 3 +- config/config.yaml | 14 ++++---- hermes.py => engram.py | 43 +++++++++++++++---------- project_registry.py | 5 ++- run_engine.py | 3 +- scaffold_project.py | 2 +- tests/test_daily_note.py | 14 ++++---- tests/test_handoff.py | 12 +++---- tests/test_handoff_system.py | 48 ++++++++++++++-------------- tests/test_hermes_endpoints.py | 2 +- tests/test_progress_note.py | 58 +++++++++++++++++----------------- tests/test_project_registry.py | 2 +- tests/test_run_task.py | 20 ++++++------ 13 files changed, 121 insertions(+), 105 deletions(-) rename hermes.py => engram.py (97%) diff --git a/agent_runner.py b/agent_runner.py index 9b8192f..5aa8595 100644 --- a/agent_runner.py +++ b/agent_runner.py @@ -33,7 +33,8 @@ RAPHBRAIN = Path.home() / "Documents" / "RaphBrain" RUN_AGENT = Path.home() / "raphael" / "agents" / "run-agent.sh" ROLES_DIR = Path.home() / "raphael" / "templates" / "roles" -CONFIG_PATH = Path.home() / "hermes" / "config" / "config.yaml" +_BASE = Path(__file__).resolve().parent # repo dir — rename/move-safe (follows the folder) +CONFIG_PATH = _BASE / "config" / "config.yaml" # Used when config.yaml is missing or has no agent_models section DEFAULT_MODEL_ROUTE = {"model": "sonnet", "max_turns": 60} diff --git a/config/config.yaml b/config/config.yaml index b20b78f..44374a3 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,17 +1,19 @@ -hermes: - name: "Hermes" - session_prefix: "hermes" - log_path: "/Users/raphael_the_great-sage/hermes/logs/hermes.log" +engram: + name: "Engram" + session_prefix: "engram" + log_path: "/Users/raphael_the_great-sage/engram/logs/engram.log" -# Telegram is the only supported trigger (see hermes.py main()). +# Telegram is the only supported trigger (see engram.py main()). trigger: method: "telegram" telegram: + # NOTE: keychain service stays "hermes-telegram-bot" — that's where the live + # bot token is stored; renaming it here would break token retrieval. bot_token_keychain_service: "hermes-telegram-bot" bot_token_keychain_account: "bot-token" chat_id: 8922766986 - trigger_prefix: "[HERMES]" + trigger_prefix: "[ENGRAM]" poll_interval_seconds: 15 models: diff --git a/hermes.py b/engram.py similarity index 97% rename from hermes.py rename to engram.py index 2532748..e7c4b7f 100644 --- a/hermes.py +++ b/engram.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ -Hermes — persistent orchestration agent for Jaiden's Mac Mini. -Listens for commands, spawns Claude Code sessions via tmux, reports back. +Engram — persistent orchestration agent for Jaiden's Mac Mini (formerly "Hermes"; +renamed to disambiguate from Nous Research's hermes-agent). +Listens for commands, dispatches Claude Code sub-agents, reports back. """ import subprocess @@ -25,10 +26,11 @@ from project_registry import project_map_text, project_names, resolve as resolve_project from scaffold_project import run_scaffold -CONFIG_PATH = Path.home() / "hermes" / "config" / "config.yaml" +_BASE = Path(__file__).resolve().parent # repo dir — rename/move-safe (follows the folder) +CONFIG_PATH = _BASE / "config" / "config.yaml" STEP_MAX_RETRIES = 1 # max automatic retries per step (not applicable to rate-limited) STEP_RETRY_DELAY_S = 10 # seconds to wait before retrying a failed step -LOG_PATH = Path.home() / "hermes" / "logs" / "hermes.log" +LOG_PATH = _BASE / "logs" / "hermes.log" # Ensure logs directory exists before configuring file handler LOG_PATH.parent.mkdir(parents=True, exist_ok=True) @@ -307,15 +309,15 @@ def run_task( `project` is echoed in the ack so a wrong route is visible immediately. """ if not session_name: - session_name = f"hermes-{int(time.time())}" + session_name = f"engram-{int(time.time())}" log.info(f"Spawning direct task: {session_name}") log.info(f"Task: {task[:120]}...") - task_file = Path.home() / "hermes" / "tasks" / f"{session_name}.md" + task_file = _BASE / "tasks" / f"{session_name}.md" task_file.parent.mkdir(parents=True, exist_ok=True) task_file.write_text(f"# Hermes Task\n\n{task}\n") - log_file = Path.home() / "hermes" / "logs" / f"{session_name}.log" + log_file = _BASE / "logs" / f"{session_name}.log" log_file.parent.mkdir(parents=True, exist_ok=True) # Direct tasks default to sonnet — the classifier sometimes routes real work @@ -448,7 +450,7 @@ def _append_pipeline_to_daily_note( # Auto-drafted skills land here for review — NEVER written straight into # ~/.claude/skills, which is globally active in every Claude Code session. Jaiden # promotes a candidate by hand once he's read it. -SKILL_CANDIDATES_DIR = Path.home() / "hermes" / "skill-candidates" +SKILL_CANDIDATES_DIR = _BASE / "skill-candidates" _PROGRESS_LOCK = threading.Lock() @@ -771,7 +773,7 @@ def _finalize_step_failure(reason: str) -> None: _send_reply( hermes_config, f"⚠️ Blocked on {role}: {reason}{handoff_line}\n\n" - f"Reply [HERMES] abort to stop, or send a corrected task to work around it.", + f"Reply [ENGRAM] abort to stop, or send a corrected task to work around it.", ) final_status = engine.poll_step_completion(run_id, idx, task_id) @@ -1102,7 +1104,7 @@ def orchestrate_task(task_text: str, config: dict) -> str: return _resume_handoff(_cmd[len("resume") :].strip(), config) if _cmd in ("help", "commands", "menu"): return ( - "Hermes commands:\n" + "Engram commands:\n" "• on , — run in a project (e.g. `on alphabot, fix the rebalance bug`)\n" "• — I'll infer the project\n" "• status — current run\n" @@ -1153,7 +1155,7 @@ def orchestrate_task(task_text: str, config: dict) -> str: except RuntimeError as exc: # Another run is already active — queue not yet supported log.warning(f"[orchestrate] create_run blocked: {exc}") - return f"⚠️ {exc}\nSend [HERMES] abort to cancel the current run first." + return f"⚠️ {exc}\nSend [ENGRAM] abort to cancel the current run first." run_id = run["id"] pipeline_summary = " → ".join(s.role for s in result.pipeline) @@ -1186,13 +1188,16 @@ class TelegramPoller: Replies via sendMessage API. """ - STATE_FILE = Path.home() / "hermes" / "config" / "telegram_state.json" + STATE_FILE = _BASE / "config" / "telegram_state.json" def __init__(self, config): self.cfg = config["trigger"]["telegram"] self.hermes_cfg = config self.chat_id = self.cfg["chat_id"] - self.prefix = self.cfg.get("trigger_prefix", "[HERMES]") + # Accept the new [ENGRAM] prefix and the old [HERMES] one (muscle-memory) — both + # optional, since a bare message from the chat is also processed. + self.prefix = self.cfg.get("trigger_prefix", "[ENGRAM]") + self.prefixes = self.cfg.get("trigger_prefixes") or [self.prefix, "[ENGRAM]", "[HERMES]"] self.token = keyring.get_password( self.cfg["bot_token_keychain_service"], self.cfg["bot_token_keychain_account"], @@ -1238,7 +1243,11 @@ def poll(self): if not text: continue - task = text[len(self.prefix) :].strip() if text.startswith(self.prefix) else text + task = text + for pfx in self.prefixes: + if text.startswith(pfx): + task = text[len(pfx) :].strip() + break self._journal(update["update_id"], task) log.info(f"Telegram task: {task[:80]}...") # Process off the poll loop: a slow classification (Ollama ~15-30s) @@ -1270,7 +1279,7 @@ def _journal(self, update_id: int, task: str): """Append the raw message before processing so a daemon crash mid-task leaves a trace instead of silently losing it (offset already advanced).""" try: - jpath = Path.home() / "hermes" / "logs" / "telegram-journal.log" + jpath = _BASE / "logs" / "telegram-journal.log" with jpath.open("a") as f: f.write(f"{update_id}\t{task}\n") except Exception: @@ -1281,10 +1290,10 @@ def _reply(self, message: str): def main(): - log.info("=== Hermes starting up ===") + log.info("=== Engram starting up ===") config = load_config() - subprocess.run([str(Path.home() / "hermes" / "scripts" / "check-connectivity.sh")]) + subprocess.run([str(_BASE / "scripts" / "check-connectivity.sh")]) # Recover crash-orphaned runs ONCE here — never per-message (see RunEngine). RunEngine().startup_recover_and_cleanup() diff --git a/project_registry.py b/project_registry.py index 3241f8a..de33293 100644 --- a/project_registry.py +++ b/project_registry.py @@ -28,7 +28,9 @@ # Repos that don't live under ~/Projects/. _EXTRA_REPOS = { - "hermes": Path.home() / "hermes", + # The daemon's own repo — resolves from this file's location so it survives the + # ~/hermes → ~/engram move. Aliased so "hermes" still routes here during transition. + "engram": Path(__file__).resolve().parent, "raphael": Path.home() / "raphael", } @@ -48,6 +50,7 @@ # so rebrands / alt spellings collapse into one entry. Only for names that are # genuinely the same project — never merge two distinct repos. _ALIASES = { + "hermes": "engram", # renamed Hermes -> Engram (disambiguate from Nous hermes-agent) "raphui": "raphael", "raph": "raphael", "mira": "vitre", # rebranded Mira -> Vitré diff --git a/run_engine.py b/run_engine.py index f5fd0a0..cf78f83 100644 --- a/run_engine.py +++ b/run_engine.py @@ -21,7 +21,8 @@ # Constants # --------------------------------------------------------------------------- -RUNS_DIR = Path.home() / "hermes" / "runs" +_BASE = Path(__file__).resolve().parent # repo dir — rename/move-safe (follows the folder) +RUNS_DIR = _BASE / "runs" TASKS_DIR = Path.home() / "raphael" / "tasks" STEP_POLL_INTERVAL_S = 5 # poll raphael task JSON every 5 seconds STEP_TIMEOUT_S = 3600 # 60-min hard timeout per step diff --git a/scaffold_project.py b/scaffold_project.py index c79f7b6..071eb60 100644 --- a/scaffold_project.py +++ b/scaffold_project.py @@ -1,7 +1,7 @@ """ scaffold_project.py — Scaffold a new Hermes-managed project. -Triggered by: [HERMES] scaffold new project: +Triggered by: [ENGRAM] scaffold new project: Creates: - ~/Projects// with src/, tests/, .github/workflows/ci.yml - Standard files: CLAUDE.md, pyproject.toml, .pre-commit-config.yaml diff --git a/tests/test_daily_note.py b/tests/test_daily_note.py index 78b93b6..15e39cd 100644 --- a/tests/test_daily_note.py +++ b/tests/test_daily_note.py @@ -1,5 +1,5 @@ """ -test_daily_note.py — unit tests for hermes._append_pipeline_to_daily_note. +test_daily_note.py — unit tests for engram._append_pipeline_to_daily_note. Salvaged from feature/pipeline-daily-note-logging. Verifies the create-vs-append branch and per-status formatting, with RAPHBRAIN_DAILY_DIR patched to a tmp dir @@ -12,15 +12,15 @@ from pathlib import Path from unittest.mock import patch -sys.path.insert(0, str(Path.home() / "hermes")) -import hermes # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import engram # noqa: E402 class TestDailyNote(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self._dir = Path(self._tmp.name) / "Daily" - self._p = patch.object(hermes, "RAPHBRAIN_DAILY_DIR", self._dir) + self._p = patch.object(engram, "RAPHBRAIN_DAILY_DIR", self._dir) self._p.start() def tearDown(self): @@ -33,20 +33,20 @@ def _today(self): return self._dir / f"{datetime.now().strftime('%Y-%m-%d')}.md" def test_creates_note_then_appends(self): - hermes._append_pipeline_to_daily_note("arbiter", "feature/x", "done", "42m") + engram._append_pipeline_to_daily_note("arbiter", "feature/x", "done", "42m") note = self._today() self.assertTrue(note.exists()) body = note.read_text() self.assertIn("✅ Hermes: `arbiter/feature/x` done in 42m", body) # Second call appends, doesn't clobber. - hermes._append_pipeline_to_daily_note("hermes", "", "failed", "12m", failed_step="tester") + engram._append_pipeline_to_daily_note("hermes", "", "failed", "12m", failed_step="tester") body = note.read_text() self.assertIn("✅ Hermes: `arbiter/feature/x` done", body) # first line survived self.assertIn("❌ Hermes: `hermes` failed at `tester` (12m)", body) def test_unknown_status_writes_nothing(self): - hermes._append_pipeline_to_daily_note("p", "b", "running", "1m") + engram._append_pipeline_to_daily_note("p", "b", "running", "1m") self.assertFalse(self._today().exists()) diff --git a/tests/test_handoff.py b/tests/test_handoff.py index 3803493..b22af49 100644 --- a/tests/test_handoff.py +++ b/tests/test_handoff.py @@ -13,8 +13,8 @@ from pathlib import Path from unittest.mock import patch -sys.path.insert(0, str(Path.home() / "hermes")) -import hermes # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import engram # noqa: E402 class _FakeResp: @@ -38,9 +38,9 @@ def test_note_records_the_given_cause(self): fake = _FakeResp({"response": "## What Was Completed\n- ran the plan step"}) with ( patch("urllib.request.urlopen", return_value=fake), - patch.object(hermes.Path, "home", return_value=Path(self._tmp())), + patch.object(engram.Path, "home", return_value=Path(self._tmp())), ): - path = hermes.write_handoff_via_ollama( + path = engram.write_handoff_via_ollama( task="get me some customers to warm call", session_name="2026-07-11-000", partial_output="some partial work", @@ -55,9 +55,9 @@ def test_default_cause_is_rate_limit(self): fake = _FakeResp({"response": "## What Was Completed\n- x"}) with ( patch("urllib.request.urlopen", return_value=fake), - patch.object(hermes.Path, "home", return_value=Path(self._tmp())), + patch.object(engram.Path, "home", return_value=Path(self._tmp())), ): - path = hermes.write_handoff_via_ollama("t", "s", "p") + path = engram.write_handoff_via_ollama("t", "s", "p") self.assertIn("usage limit reached", Path(path).read_text()) def _tmp(self) -> str: diff --git a/tests/test_handoff_system.py b/tests/test_handoff_system.py index 3dd2c49..ef3c055 100644 --- a/tests/test_handoff_system.py +++ b/tests/test_handoff_system.py @@ -13,8 +13,8 @@ from pathlib import Path from unittest.mock import patch -sys.path.insert(0, str(Path.home() / "hermes")) -import hermes # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import engram # noqa: E402 class _FakeProj: @@ -28,8 +28,8 @@ def setUp(self): self._dir = Path(self._tmp.name) / "handoffs" self._dir.mkdir(parents=True) self._resolved = self._dir / "resolved" - self._p1 = patch.object(hermes, "HANDOFFS_DIR", self._dir) - self._p2 = patch.object(hermes, "HANDOFFS_RESOLVED_DIR", self._resolved) + self._p1 = patch.object(engram, "HANDOFFS_DIR", self._dir) + self._p2 = patch.object(engram, "HANDOFFS_RESOLVED_DIR", self._resolved) self._p1.start() self._p2.start() @@ -46,12 +46,12 @@ def _write(self, stem, project="demo", task="ship the thing"): def test_parse_meta(self): self._write("t1", project="finance-tracker", task="fix parser") - meta = hermes._parse_handoff_meta((self._dir / "t1-handoff.md").read_text()) + meta = engram._parse_handoff_meta((self._dir / "t1-handoff.md").read_text()) self.assertEqual(meta["project"], "finance-tracker") self.assertEqual(meta["task"], "fix parser") def test_parse_meta_legacy_no_marker(self): - meta = hermes._parse_handoff_meta("# Auto-Handoff\n\n> **Original task:** old one\n") + meta = engram._parse_handoff_meta("# Auto-Handoff\n\n> **Original task:** old one\n") self.assertEqual(meta["project"], "") self.assertEqual(meta["task"], "old one") @@ -60,33 +60,33 @@ def test_pending_excludes_resolved_and_is_newest_first(self): self._write("newer") os.utime(self._dir / "newer-handoff.md", (time.time() + 10, time.time() + 10)) self.assertEqual( - [p.stem for p in hermes._pending_handoffs()], ["newer-handoff", "older-handoff"] + [p.stem for p in engram._pending_handoffs()], ["newer-handoff", "older-handoff"] ) self._resolved.mkdir(parents=True, exist_ok=True) (self._resolved / "gone-handoff.md").write_text("x") - self.assertNotIn("gone-handoff", [p.stem for p in hermes._pending_handoffs()]) + self.assertNotIn("gone-handoff", [p.stem for p in engram._pending_handoffs()]) def test_list_empty(self): - self.assertIn("No pending handoffs", hermes._list_handoffs()) + self.assertIn("No pending handoffs", engram._list_handoffs()) def test_resume_dispatches_into_repo_then_archives(self): self._write("t1", project="demo", task="do it") with ( - patch.object(hermes, "run_task", return_value="▶ started") as rt, - patch.object(hermes, "resolve_project", return_value=_FakeProj()), - patch.object(hermes, "_send_reply"), + patch.object(engram, "run_task", return_value="▶ started") as rt, + patch.object(engram, "resolve_project", return_value=_FakeProj()), + patch.object(engram, "_send_reply"), ): - out = hermes._resume_handoff("1", {}) + out = engram._resume_handoff("1", {}) rt.assert_called_once() self.assertEqual(rt.call_args.kwargs["cwd"], "~/Projects/demo") self.assertEqual(rt.call_args.kwargs["project"], "demo") self.assertIn("Resuming", out) - self.assertEqual(hermes._pending_handoffs(), []) # left the pending list + self.assertEqual(engram._pending_handoffs(), []) # left the pending list self.assertTrue((self._resolved / "t1-handoff.md").exists()) # archived def test_resume_no_match(self): self._write("t1") - self.assertIn("matches", hermes._resume_handoff("9", {})) + self.assertIn("matches", engram._resume_handoff("9", {})) def test_out_of_range_index_never_substring_matches_a_stem(self): # stems embed unix timestamps; `resume 9` (out of range) must error, NOT @@ -95,25 +95,25 @@ def test_out_of_range_index_never_substring_matches_a_stem(self): '\n# h\n> **Original task:** t\n' ) with ( - patch.object(hermes, "run_task") as rt, - patch.object(hermes, "resolve_project", return_value=_FakeProj()), + patch.object(engram, "run_task") as rt, + patch.object(engram, "resolve_project", return_value=_FakeProj()), ): - out = hermes._resume_handoff("9", {}) + out = engram._resume_handoff("9", {}) rt.assert_not_called() self.assertIn("matches", out) self.assertTrue((self._dir / "hermes-1753000009-handoff.md").exists()) # not archived def test_resume_bare_lists_pending(self): self._write("t1") - self.assertIn("Pending handoffs", hermes._resume_handoff("", {})) + self.assertIn("Pending handoffs", engram._resume_handoff("", {})) def test_resume_unresolvable_project_does_not_dispatch(self): self._write("t1", project="") # legacy, no marker with ( - patch.object(hermes, "run_task") as rt, - patch.object(hermes, "classify_task", side_effect=Exception("no ollama")), + patch.object(engram, "run_task") as rt, + patch.object(engram, "classify_task", side_effect=Exception("no ollama")), ): - out = hermes._resume_handoff("1", {}) + out = engram._resume_handoff("1", {}) rt.assert_not_called() self.assertIn("Couldn't map", out) self.assertTrue((self._dir / "t1-handoff.md").exists()) # NOT archived on failure @@ -121,8 +121,8 @@ def test_resume_unresolvable_project_does_not_dispatch(self): def test_clear_moves_all(self): self._write("a") self._write("b") - self.assertIn("Cleared 2", hermes._clear_handoffs()) - self.assertEqual(hermes._pending_handoffs(), []) + self.assertIn("Cleared 2", engram._clear_handoffs()) + self.assertEqual(engram._pending_handoffs(), []) self.assertTrue((self._resolved / "a-handoff.md").exists()) diff --git a/tests/test_hermes_endpoints.py b/tests/test_hermes_endpoints.py index 31c82de..4f89e08 100644 --- a/tests/test_hermes_endpoints.py +++ b/tests/test_hermes_endpoints.py @@ -77,7 +77,7 @@ def setUp(self): self._runs_tmpdir = tempfile.TemporaryDirectory() self._log_tmpdir = tempfile.TemporaryDirectory() self.runs_path = Path(self._runs_tmpdir.name) - self.log_path = Path(self._log_tmpdir.name) / "hermes.log" + self.log_path = Path(self._log_tmpdir.name) / "engram.log" # Patch HERMES_RUNS_DIR and HERMES_LOG in server module before importing app import server as srv diff --git a/tests/test_progress_note.py b/tests/test_progress_note.py index 2ea673a..e84a980 100644 --- a/tests/test_progress_note.py +++ b/tests/test_progress_note.py @@ -12,8 +12,8 @@ from pathlib import Path from unittest.mock import patch -sys.path.insert(0, str(Path.home() / "hermes")) -import hermes # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import engram # noqa: E402 class _FakeProj: @@ -34,11 +34,11 @@ class TestProgressNote(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self._dir = Path(self._tmp.name) / "Projects" - self._p = patch.object(hermes, "RAPHBRAIN_PROJECTS_DIR", self._dir) + self._p = patch.object(engram, "RAPHBRAIN_PROJECTS_DIR", self._dir) self._p.start() # Default: no registry hit → falls back to project.capitalize() (deterministic, # no dependency on the real ~/Projects layout). H2 test overrides this. - self._rp = patch.object(hermes, "resolve_project", return_value=None) + self._rp = patch.object(engram, "resolve_project", return_value=None) self._rp.start() def tearDown(self): @@ -51,7 +51,7 @@ def _progress(self, proj="Arbiter"): def test_creates_file_and_section_when_missing(self): # done ⇒ a note MUST exist even if Progress.md didn't. - hermes._append_pipeline_to_progress_note( + engram._append_pipeline_to_progress_note( "arbiter", "feature/x", "done", @@ -61,13 +61,13 @@ def test_creates_file_and_section_when_missing(self): step_count=4, ) body = self._progress().read_text() - self.assertIn(hermes.HERMES_RUN_LOG_HEADER, body) + self.assertIn(engram.HERMES_RUN_LOG_HEADER, body) self.assertIn("✅ arbiter/feature/x — done", body) self.assertIn("4/4 steps · 12m", body) self.assertIn("PR: http://pr/1", body) def test_failed_entry_records_step_and_reason(self): - hermes._append_pipeline_to_progress_note( + engram._append_pipeline_to_progress_note( "arbiter", "", "failed", @@ -86,9 +86,9 @@ def test_newest_first_and_preserves_human_sections(self): p.parent.mkdir(parents=True, exist_ok=True) p.write_text( "# Arbiter — Progress\n\n## Status\nhand-curated\n\n" - f"{hermes.HERMES_RUN_LOG_HEADER}\n### old entry\n" + f"{engram.HERMES_RUN_LOG_HEADER}\n### old entry\n" ) - hermes._append_pipeline_to_progress_note( + engram._append_pipeline_to_progress_note( "arbiter", "feature/new", "done", "1m", done_count=1, step_count=1 ) body = p.read_text() @@ -100,25 +100,25 @@ def test_header_without_trailing_newline_stays_one_section(self): # must NOT glue onto the header, and a SECOND run must not append a 2nd section. p = self._progress() p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(f"# Arbiter — Progress\n\n{hermes.HERMES_RUN_LOG_HEADER}") - hermes._append_pipeline_to_progress_note( + p.write_text(f"# Arbiter — Progress\n\n{engram.HERMES_RUN_LOG_HEADER}") + engram._append_pipeline_to_progress_note( "arbiter", "one", "done", "1m", step_count=1, done_count=1 ) - hermes._append_pipeline_to_progress_note( + engram._append_pipeline_to_progress_note( "arbiter", "two", "done", "1m", step_count=1, done_count=1 ) body = p.read_text() - self.assertEqual(body.count(hermes.HERMES_RUN_LOG_HEADER), 1) # still one section + self.assertEqual(body.count(engram.HERMES_RUN_LOG_HEADER), 1) # still one section self.assertIn( - f"{hermes.HERMES_RUN_LOG_HEADER}\n", body + f"{engram.HERMES_RUN_LOG_HEADER}\n", body ) # header on its own line, not glued - self.assertNotIn(f"{hermes.HERMES_RUN_LOG_HEADER}###", body) + self.assertNotIn(f"{engram.HERMES_RUN_LOG_HEADER}###", body) self.assertLess(body.index("two"), body.index("one")) # newest-first preserved def test_note_uses_registry_folder_not_capitalize(self): # hyphenated project must land in the registry folder, not a capitalize() orphan. - with patch.object(hermes, "resolve_project", return_value=_FakeProj("FinanceTracker")): - hermes._append_pipeline_to_progress_note( + with patch.object(engram, "resolve_project", return_value=_FakeProj("FinanceTracker")): + engram._append_pipeline_to_progress_note( "finance-tracker", "b", "done", "1m", done_count=1, step_count=1 ) self.assertTrue((self._dir / "FinanceTracker" / "Progress.md").exists()) @@ -127,7 +127,7 @@ def test_note_uses_registry_folder_not_capitalize(self): # --- post-task review parsing / sanitizing --- def test_split_review_literal_no_skill(self): - learnings, skill = hermes._split_review( + learnings, skill = engram._split_review( "## Learnings\n- did a thing\n\n## Skill Candidate\nNO_SKILL" ) self.assertIn("did a thing", learnings) @@ -136,7 +136,7 @@ def test_split_review_literal_no_skill(self): def test_split_review_paraphrased_no_skill_stages_nothing(self): # the 8B model rarely emits the literal token — prose must not become a skill. for prose in ("No skill needed here.", "We could maybe reuse the deploy step."): - _, skill = hermes._split_review(f"## Learnings\n- x\n\n## Skill Candidate\n{prose}") + _, skill = engram._split_review(f"## Learnings\n- x\n\n## Skill Candidate\n{prose}") self.assertEqual(skill, "", prose) def test_split_review_extracts_real_frontmatter_skill(self): @@ -144,30 +144,30 @@ def test_split_review_extracts_real_frontmatter_skill(self): "## Learnings\n- reusable flow\n\n## Skill Candidate\n" "---\nname: deploy-vercel-site\ndescription: ship a static site\n---\n1. build\n2. deploy" ) - learnings, skill = hermes._split_review(text) + learnings, skill = engram._split_review(text) self.assertIn("reusable flow", learnings) self.assertTrue(skill.startswith("---")) - self.assertEqual(hermes._skill_name(skill), "deploy-vercel-site") + self.assertEqual(engram._skill_name(skill), "deploy-vercel-site") def test_skill_name_sanitizes_path_traversal(self): # the name is model output used as a path — it must never escape the staging dir. self.assertEqual( - hermes._skill_name("name: ../../.claude/skills/evil"), "claude-skills-evil" + engram._skill_name("name: ../../.claude/skills/evil"), "claude-skills-evil" ) - self.assertEqual(hermes._skill_name("name: /etc/passwd"), "etc-passwd") - self.assertEqual(hermes._skill_name('name: "My Cool Skill!"'), "my-cool-skill") - self.assertEqual(hermes._skill_name("no frontmatter"), "") + self.assertEqual(engram._skill_name("name: /etc/passwd"), "etc-passwd") + self.assertEqual(engram._skill_name('name: "My Cool Skill!"'), "my-cool-skill") + self.assertEqual(engram._skill_name("no frontmatter"), "") # --- end-to-end staging path (security-relevant: model output → file on disk) --- def _run_review(self, review_out, staging): with ( - patch.object(hermes, "SKILL_CANDIDATES_DIR", staging), - patch.object(hermes, "_ollama_generate", return_value=review_out), - patch.object(hermes, "_send_reply") as reply, + patch.object(engram, "SKILL_CANDIDATES_DIR", staging), + patch.object(engram, "_ollama_generate", return_value=review_out), + patch.object(engram, "_send_reply") as reply, ): engine = _FakeEngine({"task_raw": "do a thing", "pipeline": []}) - hermes.spawn_post_task_review(engine, "rid", "arbiter", "feat/x", {}) + engram.spawn_post_task_review(engine, "rid", "arbiter", "feat/x", {}) for t in list(threading.enumerate()): if t.name == "hermes-review-rid": t.join(timeout=5) diff --git a/tests/test_project_registry.py b/tests/test_project_registry.py index aaa8292..b5f9496 100644 --- a/tests/test_project_registry.py +++ b/tests/test_project_registry.py @@ -11,7 +11,7 @@ import unittest from pathlib import Path -sys.path.insert(0, str(Path.home() / "hermes")) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import project_registry as pr # noqa: E402 diff --git a/tests/test_run_task.py b/tests/test_run_task.py index f78a277..4022a6a 100644 --- a/tests/test_run_task.py +++ b/tests/test_run_task.py @@ -1,5 +1,5 @@ """ -test_run_task.py — unit tests for hermes.run_task background execution. +test_run_task.py — unit tests for engram.run_task background execution. Covers the two behaviors that replaced the old tmux + HERMES_DONE sentinel + 30-min poll loop: @@ -17,8 +17,8 @@ from pathlib import Path from unittest.mock import MagicMock, patch -sys.path.insert(0, str(Path.home() / "hermes")) -import hermes # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import engram # noqa: E402 class _FakeProc: @@ -48,7 +48,7 @@ def cb(r: str) -> None: done.set() with patch("subprocess.Popen", return_value=fake): - ack = hermes.run_task(task, session_name="test-runtask", on_complete=cb) + ack = engram.run_task(task, session_name="test-runtask", on_complete=cb) assert done.wait(timeout=10), "on_complete never fired" return ack, box["result"], fake @@ -56,7 +56,7 @@ def cb(r: str) -> None: class TestRunTask(unittest.TestCase): def setUp(self): # Hermetic, fast config lookup — don't depend on config.yaml contents. - self._cfg = patch.object(hermes, "load_config", return_value={}) + self._cfg = patch.object(engram, "load_config", return_value={}) self._cfg.start() def tearDown(self): @@ -87,29 +87,29 @@ class TestDirectTaskControl(unittest.TestCase): """Live direct tasks are visible to `status` and killable by `abort`.""" def tearDown(self): - hermes._DIRECT_TASKS.clear() + engram._DIRECT_TASKS.clear() def test_summary_lists_task(self): - hermes._DIRECT_TASKS["hermes-t1"] = { + engram._DIRECT_TASKS["hermes-t1"] = { "task": "check the rebalance", "project": "alphabot", "started": time.time() - 65, "proc": MagicMock(), } - s = hermes._direct_tasks_summary() + s = engram._direct_tasks_summary() self.assertIn("hermes-t1", s) self.assertIn("alphabot", s) self.assertIn("1m", s) # ~65s elapsed def test_abort_kills_all(self): proc = MagicMock() - hermes._DIRECT_TASKS["hermes-t2"] = { + engram._DIRECT_TASKS["hermes-t2"] = { "task": "x", "project": None, "started": time.time(), "proc": proc, } - n = hermes._abort_direct_tasks() + n = engram._abort_direct_tasks() self.assertEqual(n, 1) proc.kill.assert_called_once()