{"repo":"NousResearch/hermes-agent","public_id":"452","display_label":"Filesystem checkpointing and rollback before destructive operations","summary":"These members all concern a proposed feature for automatically creating checkpoints or backups of the filesystem before destructive file modifications, with support for rolling back to a prior state. Several reference Git-based or native checkpoint mechanisms and a rollback command.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":824},"resolution":{"state":"partially_resolved","by":"pr-824"},"freshness":{"state":"stale","last_verified_at":"2026-03-10T07:51:32+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.27199999999999996},"fix_edges":[{"issue_number":452,"pr_number":824,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":452,"title":"[Feature]: feat: filesystem checkpointing and rollback before destructive operations","url":null,"state":"closed"},{"kind":"issue","number":4489,"title":"[Feature]: Auto-Checkpoint with Git Before File Modifications","url":null,"state":"closed"},{"kind":"issue","number":29062,"title":"Auto-backup before every operation with instant rollback support","url":null,"state":"closed"},{"kind":"pr","number":559,"title":"feat(checkpoint): filesystem checkpointing and rollback before destructive operations","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":824,"title":"feat: filesystem checkpoints and /rollback command","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/main.py","run_agent.py","tests/tools/test_checkpoint_manager.py","tools/checkpoint_manager.py","website/docs/reference/cli-commands.md","website/docs/user-guide/configuration.md","website/docs/user-guide/features/checkpoints.md"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -771,7 +771,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n         _known_commands = {\"new\", \"reset\", \"help\", \"status\", \"stop\", \"model\",\n                           \"personality\", \"retry\", \"undo\", \"sethome\", \"set-home\",\n                           \"compress\", \"usage\", \"insights\", \"reload-mcp\", \"reload_mcp\",\n-                          \"update\", \"title\", \"resume\", \"provider\"}\n+                          \"update\", \"title\", \"resume\", \"provider\", \"rollback\"}\n         if command and command in _known_commands:\n             await self.hooks.emit(f\"command:{command}\", {\n                 \"platform\": source.platform.value if source.platform else \"\",","@@ -830,6 +830,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n \n         if command == \"resume\":\n             return await self._handle_resume_command(event)\n+\n+        if command == \"rollback\":\n+            return await self._handle_rollback_command(event)\n         \n         # Skill slash commands: /skill-name loads the skill and sends to agent\n         if command:","@@ -1400,6 +1403,7 @@ async def _handle_help_command(self, event: MessageEvent) -> str:\n             \"`/resume [name]` — Resume a previously-named session\",\n             \"`/usage` — Show token usage for this session\",\n             \"`/insights [days]` — Show usage insights and analytics\",\n+            \"`/rollback [number]` — List or restore filesystem checkpoints\",\n             \"`/reload-mcp` — Reload MCP servers from config\",\n             \"`/update` — Update Hermes Agent to the latest version\",\n             \"`/help` — Show this message\",","@@ -1746,6 +1750,65 @@ async def _handle_set_home_command(self, event: MessageEvent) -> str:\n             f\"Cron jobs and cross-platform messages will be delivered here.\"\n         )\n     \n+    async def _handle_rollback_command(self, event: MessageEvent) -> str:\n+        \"\"\"Handle /rollback command — list or restore filesystem checkpoints.\"\"\"\n+        from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list\n+\n+        # Read checkpoint config from config.yaml\n+        cp_cfg = {}\n+        try:\n+            import yaml as _y\n+            _cfg_path = _hermes_home / \"config.yaml\"\n+            if _cfg_path.exists():\n+                with open(_cfg_path, encoding=\"utf-8\") as _f:\n+                    _data = _y.safe_load(_f) or {}\n+                cp_cfg = _data.get(\"checkpoints\", {})\n+                if isinstance(cp_cfg, bool):\n+                    cp_cfg = {\"enabled\": cp_cfg}\n+        except Exception:\n+            pass\n+\n+        if not cp_cfg.get(\"enabled\", False):\n+            return (\n+                \"Checkpoints are not enabled.\\n\"\n+                \"Enable in config.yaml:\\n```\\ncheckpoints:\\n  enabled: true\\n```\"\n+            )\n+\n+        mgr = CheckpointManager(\n+            enabled=True,\n+            max_snapshots=cp_cfg.get(\"max_snapshots\", 50),\n+        )\n+\n+        cwd = os.getenv(\"MESSAGING_CWD\", str(Path.home()))\n+        arg = event.get_command_args().strip()\n+\n+        if not arg:\n+            checkpoints = mgr.list_checkpoints(cwd)\n+            return format_checkpoint_list(checkpoints, cwd)\n+\n+        # Restore by number or hash\n+        checkpoints = mgr.list_checkpoints(cwd)\n+        if not checkpoints:\n+            return f\"No checkpoints found for {cwd}\"\n+\n+        target_hash = None\n+        try:\n+            idx = int(arg) - 1\n+            if 0 <= idx < len(checkpoints):\n+                target_hash = checkpoints[idx][\"hash\"]\n+            else:\n+                return f\"Invalid checkpoint number. Use 1-{len(checkpoints)}.\"\n+        except ValueError:\n+            target_hash = arg\n+\n+        result = mgr.restore(cwd, target_hash)\n+        if result[\"success\"]:\n+            return (\n+                f\"✅ Restored to checkpoint {result['restored_to']}: {result['reason']}\\n\"\n+                f\"A pre-rollback snapshot was saved automatically.\"\n+            )\n+        return f\"❌ {result['error']}\"\n+\n     async def _handle_compress_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /compress command -- manually compress conversation context.\"\"\"\n         source = event.source"]},{"file":"hermes_cli/main.py","hunks":["@@ -489,6 +489,7 @@ def cmd_chat(args):\n         \"query\": args.query,\n         \"resume\": getattr(args, \"resume\", None),\n         \"worktree\": getattr(args, \"worktree\", False),\n+        \"checkpoints\": getattr(args, \"checkpoints\", False),\n     }\n     # Filter out None values\n     kwargs = {k: v for k, v in kwargs.items() if v is not None}","@@ -1927,6 +1928,12 @@ def main():\n         default=False,\n         help=\"Run in an isolated git worktree (for parallel agents on the same repo)\"\n     )\n+    chat_parser.add_argument(\n+        \"--checkpoints\",\n+        action=\"store_true\",\n+        default=False,\n+        help=\"Enable filesystem checkpoints before destructive file operations (use /rollback to restore)\"\n+    )\n     chat_parser.set_defaults(func=cmd_chat)\n \n     # ========================================================================="]},{"file":"run_agent.py","hunks":["@@ -185,6 +185,8 @@ def __init__(\n         honcho_session_key: str = None,\n         iteration_budget: \"IterationBudget\" = None,\n         fallback_model: Dict[str, Any] = None,\n+        checkpoints_enabled: bool = False,\n+        checkpoint_max_snapshots: int = 50,\n     ):\n         \"\"\"\n         Initialize the AI Agent.","@@ -486,6 +488,13 @@ def __init__(\n         # Cached system prompt -- built once per session, only rebuilt on compression\n         self._cached_system_prompt: Optional[str] = None\n         \n+        # Filesystem checkpoint manager (transparent — not a tool)\n+        from tools.checkpoint_manager import CheckpointManager\n+        self._checkpoint_mgr = CheckpointManager(\n+            enabled=checkpoints_enabled,\n+            max_snapshots=checkpoint_max_snapshots,\n+        )\n+        \n         # SQLite session store (optional -- provided by CLI or gateway)\n         self._session_db = session_db\n         if self._session_db:","@@ -2706,6 +2715,18 @@ def _execute_tool_calls(self, assistant_message, messages: list, effective_task_\n                 except Exception as cb_err:\n                     logging.debug(f\"Tool progress callback error: {cb_err}\")\n \n+            # Checkpoint: snapshot working dir before file-mutating tools\n+            if function_name in (\"write_file\", \"patch\") and self._checkpoint_mgr.enabled:\n+                try:\n+                    file_path = function_args.get(\"path\", \"\")\n+                    if file_path:\n+                        work_dir = self._checkpoint_mgr.get_working_dir_for_path(file_path)\n+                        self._checkpoint_mgr.ensure_checkpoint(\n+                            work_dir, f\"before {function_name}\"\n+                        )\n+                except Exception:\n+                    pass  # never block tool execution\n+\n             tool_start_time = time.time()\n \n             if function_name == \"todo\":","@@ -3215,6 +3236,9 @@ def run_conversation(\n         self.clear_interrupt()\n         \n         while api_call_count < self.max_iterations and self.iteration_budget.remaining > 0:\n+            # Reset per-turn checkpoint dedup so each iteration can take one snapshot\n+            self._checkpoint_mgr.new_turn()\n+\n             # Check for interrupt request (e.g., user sent new message)\n             if self._interrupt_requested:\n                 interrupted = True"]},{"file":"tests/tools/test_checkpoint_manager.py","hunks":["@@ -0,0 +1,385 @@\n+\"\"\"Tests for tools/checkpoint_manager.py — CheckpointManager.\"\"\"\n+\n+import os\n+import json\n+import shutil\n+import pytest\n+from pathlib import Path\n+from unittest.mock import patch\n+\n+from tools.checkpoint_manager import (\n+    CheckpointManager,\n+    _shadow_repo_path,\n+    _init_shadow_repo,\n+    _run_git,\n+    _git_env,\n+    _dir_file_count,\n+    format_checkpoint_list,\n+    DEFAULT_EXCLUDES,\n+    CHECKPOINT_BASE,\n+)\n+\n+\n+# =========================================================================\n+# Fixtures\n+# =========================================================================\n+\n[redacted:email]()\n+def work_dir(tmp_path):\n+    \"\"\"Temporary working directory.\"\"\"\n+    d = tmp_path / \"project\"\n+    d.mkdir()\n+    (d / \"main.py\").write_text(\"print('hello')\\\\n\")\n+    (d / \"README.md\").write_text(\"# Project\\\\n\")\n+    return d\n+\n+\n[redacted:email]()\n+def checkpoint_base(tmp_path):\n+    \"\"\"Isolated checkpoint base — never writes to ~/.hermes/.\"\"\"\n+    return tmp_path / \"checkpoints\"\n+\n+\n[redacted:email]()\n+def mgr(work_dir, checkpoint_base, monkeypatch):\n+    \"\"\"CheckpointManager with redirected checkpoint base.\"\"\"\n+    monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+    return CheckpointManager(enabled=True, max_snapshots=50)\n+\n+\n[redacted:email]()\n+def disabled_mgr(checkpoint_base, monkeypatch):\n+    \"\"\"Disabled CheckpointManager.\"\"\"\n+    monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+    return CheckpointManager(enabled=False)\n+\n+\n+# =========================================================================\n+# Shadow repo path\n+# =========================================================================\n+\n+class TestShadowRepoPath:\n+    def test_deterministic(self, work_dir, checkpoint_base, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        p1 = _shadow_repo_path(str(work_dir))\n+        p2 = _shadow_repo_path(str(work_dir))\n+        assert p1 == p2\n+\n+    def test_different_dirs_different_paths(self, tmp_path, checkpoint_base, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        p1 = _shadow_repo_path(str(tmp_path / \"a\"))\n+        p2 = _shadow_repo_path(str(tmp_path / \"b\"))\n+        assert p1 != p2\n+\n+    def test_under_checkpoint_base(self, work_dir, checkpoint_base, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        p = _shadow_repo_path(str(work_dir))\n+        assert str(p).startswith(str(checkpoint_base))\n+\n+\n+# =========================================================================\n+# Shadow repo init\n+# =========================================================================\n+\n+class TestShadowRepoInit:\n+    def test_creates_git_repo(self, work_dir, checkpoint_base, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        shadow = _shadow_repo_path(str(work_dir))\n+        err = _init_shadow_repo(shadow, str(work_dir))\n+        assert err is None\n+        assert (shadow / \"HEAD\").exists()\n+\n+    def test_no_git_in_project_dir(self, work_dir, checkpoint_base, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        shadow = _shadow_repo_path(str(work_dir))\n+        _init_shadow_repo(shadow, str(work_dir))\n+        assert not (work_dir / \".git\").exists()\n+\n+    def test_has_exclude_file(self, work_dir, checkpoint_base, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        shadow = _shadow_repo_path(str(work_dir))\n+        _init_shadow_repo(shadow, str(work_dir))\n+        exclude = shadow / \"info\" / \"exclude\"\n+        assert exclude.exists()\n+        content = exclude.read_text()\n+        assert \"node_modules/\" in content\n+        assert \".env\" in content\n+\n+    def test_has_workdir_file(self, work_dir, checkpoint_base, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        shadow = _shadow_repo_path(str(work_dir))\n+        _init_shadow_repo(shadow, str(work_dir))\n+        workdir_file = shadow / \"HERMES_WORKDIR\"\n+        assert workdir_file.exists()\n+        assert str(work_dir.resolve()) in workdir_file.read_text()\n+\n+    def test_idempotent(self, work_dir, checkpoint_base, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        shadow = _shadow_repo_path(str(work_dir))\n+        err1 = _init_shadow_repo(shadow, str(work_dir))\n+        err2 = _init_shadow_repo(shadow, str(work_dir))\n+        assert err1 is None\n+        assert err2 is None\n+\n+\n+# =========================================================================\n+# CheckpointManager — disabled\n+# =========================================================================\n+\n+class TestDisabledManager:\n+    def test_ensure_checkpoint_returns_false(self, disabled_mgr, work_dir):\n+        assert disabled_mgr.ensure_checkpoint(str(work_dir)) is False\n+\n+    def test_new_turn_works(self, disabled_mgr):\n+        disabled_mgr.new_turn()  # should not raise\n+\n+\n+# =========================================================================\n+# CheckpointManager — taking checkpoints\n+# =========================================================================\n+\n+class TestTakeCheckpoint:\n+    def test_first_checkpoint(self, mgr, work_dir):\n+        result = mgr.ensure_checkpoint(str(work_dir), \"initial\")\n+        assert result is True\n+\n+    def test_dedup_same_turn(self, mgr, work_dir):\n+        r1 = mgr.ensure_checkpoint(str(work_dir), \"first\")\n+        r2 = mgr.ensure_checkpoint(str(work_dir), \"second\")\n+        assert r1 is True\n+        assert r2 is False  # dedup'd\n+\n+    def test_new_turn_resets_dedup(self, mgr, work_dir):\n+        r1 = mgr.ensure_checkpoint(str(work_dir), \"turn 1\")\n+        assert r1 is True\n+\n+        mgr.new_turn()\n+\n+        # Modify a file so there's something to commit\n+        (work_dir / \"main.py\").write_text(\"print('modified')\\\\n\")\n+        r2 = mgr.ensure_checkpoint(str(work_dir), \"turn 2\")\n+        assert r2 is True\n+\n+    def test_no_changes_skips_commit(self, mgr, work_dir):\n+        # First checkpoint\n+        mgr.ensure_checkpoint(str(work_dir), \"initial\")\n+        mgr.new_turn()\n+\n+        # No file changes — should return False (nothing to commit)\n+        r = mgr.ensure_checkpoint(str(work_dir), \"no changes\")\n+        assert r is False\n+\n+    def test_skip_root_dir(self, mgr):\n+        r = mgr.ensure_checkpoint(\"/\", \"root\")\n+        assert r is False\n+\n+    def test_skip_home_dir(self, mgr):\n+        r = mgr.ensure_checkpoint(str(Path.home()), \"home\")\n+        assert r is False\n+\n+\n+# =========================================================================\n+# CheckpointManager — listing checkpoints\n+# =========================================================================\n+\n+class TestListCheckpoints:\n+    def test_empty_when_no_checkpoints(self, mgr, work_dir):\n+        result = mgr.list_checkpoints(str(work_dir))\n+        assert result == []\n+\n+    def test_list_after_take(self, mgr, work_dir):\n+        mgr.ensure_checkpoint(str(work_dir), \"test checkpoint\")\n+        result = mgr.list_checkpoints(str(work_dir))\n+        assert len(result) == 1\n+        assert result[0][\"reason\"] == \"test checkpoint\"\n+        assert \"hash\" in result[0]\n+        assert \"short_hash\" in result[0]\n+        assert \"timestamp\" in result[0]\n+\n+    def test_multiple_checkpoints_ordered(self, mgr, work_dir):\n+        mgr.ensure_checkpoint(str(work_dir), \"first\")\n+        mgr.new_turn()\n+\n+        (work_dir / \"main.py\").write_text(\"v2\\\\n\")\n+        mgr.ensure_checkpoint(str(work_dir), \"second\")\n+        mgr.new_turn()\n+\n+        (work_dir / \"main.py\").write_text(\"v3\\\\n\")\n+        mgr.ensure_checkpoint(str(work_dir), \"third\")\n+\n+        result = mgr.list_checkpoints(str(work_dir))\n+        assert len(result) == 3\n+        # Most recent first\n+        assert result[0][\"reason\"] == \"third\"\n+        assert result[2][\"reason\"] == \"first\"\n+\n+\n+# =========================================================================\n+# CheckpointManager — restoring\n+# =========================================================================\n+\n+class TestRestore:\n+    def test_restore_to_previous(self, mgr, work_dir):\n+        # Write original content\n+        (work_dir / \"main.py\").write_text(\"original\\\\n\")\n+        mgr.ensure_checkpoint(str(work_dir), \"original state\")\n+        mgr.new_turn()\n+\n+        # Modify the file\n+        (work_dir / \"main.py\").write_text(\"modified\\\\n\")\n+\n+        # Get the checkpoint hash\n+        checkpoints = mgr.list_checkpoints(str(work_dir))\n+        assert len(checkpoints) == 1\n+\n+        # Restore\n+        result = mgr.restore(str(work_dir), checkpoints[0][\"hash\"])\n+        assert result[\"success\"] is True\n+\n+        # File should be back to original\n+        assert (work_dir / \"main.py\").read_text() == \"original\\\\n\"\n+\n+    def test_restore_invalid_hash(self, mgr, work_dir):\n+        mgr.ensure_checkpoint(str(work_dir), \"initial\")\n+        result = mgr.restore(str(work_dir), \"deadbeef1234\")\n+        assert result[\"success\"] is False\n+\n+    def test_restore_no_checkpoints(self, mgr, work_dir):\n+        result = mgr.restore(str(work_dir), \"abc123\")\n+        assert result[\"success\"] is False\n+\n+    def test_restore_creates_pre_rollback_snapshot(self, mgr, work_dir):\n+        (work_dir / \"main.py\").write_text(\"v1\\\\n\")\n+        mgr.ensure_checkpoint(str(work_dir), \"v1\")\n+        mgr.new_turn()\n+\n+        (work_dir / \"main.py\").write_text(\"v2\\\\n\")\n+\n+        checkpoints = mgr.list_checkpoints(str(work_dir))\n+        mgr.restore(str(work_dir), checkpoints[0][\"hash\"])\n+\n+        # Should now have 2 checkpoints: original + pre-rollback\n+        all_cps = mgr.list_checkpoints(str(work_dir))\n+        assert len(all_cps) >= 2\n+        assert \"pre-rollback\" in all_cps[0][\"reason\"]\n+\n+\n+# =========================================================================\n+# CheckpointManager — working dir resolution\n+# =========================================================================\n+\n+class TestWorkingDirResolution:\n+    def test_resolves_git_project_root(self, tmp_path):\n+        mgr = CheckpointManager(enabled=True)\n+        project = tmp_path / \"myproject\"\n+        project.mkdir()\n+        (project / \".git\").mkdir()\n+        subdir = project / \"src\"\n+        subdir.mkdir()\n+        filepath = subdir / \"main.py\"\n+        filepath.write_text(\"x\\\\n\")\n+\n+        result = mgr.get_working_dir_for_path(str(filepath))\n+        assert result == str(project)\n+\n+    def test_resolves_pyproject_root(self, tmp_path):\n+        mgr = CheckpointManager(enabled=True)\n+        project = tmp_path / \"pyproj\"\n+        project.mkdir()\n+        (project / \"pyproject.toml\").write_text(\"[project]\\\\n\")\n+        subdir = project / \"src\"\n+        subdir.mkdir()\n+\n+        result = mgr.get_working_dir_for_path(str(subdir / \"file.py\"))\n+        assert result == str(project)\n+\n+    def test_falls_back_to_parent(self, tmp_path):\n+        mgr = CheckpointManager(enabled=True)\n+        filepath = tmp_path / \"random\" / \"file.py\"\n+        filepath.parent.mkdir(parents=True)\n+        filepath.write_text(\"x\\\\n\")\n+\n+        result = mgr.get_working_dir_for_path(str(filepath))\n+        assert result == str(filepath.parent)\n+\n+\n+# =========================================================================\n+# Git env isolation\n+# =========================================================================\n+\n+class TestGitEnvIsolation:\n+    def test_sets_git_dir(self, tmp_path):\n+        shadow = tmp_path / \"shadow\"\n+        env = _git_env(shadow, str(tmp_path / \"work\"))\n+        assert env[\"GIT_DIR\"] == str(shadow)\n+\n+    def test_sets_work_tree(self, tmp_path):\n+        shadow = tmp_path / \"shadow\"\n+        work = tmp_path / \"work\"\n+        env = _git_env(shadow, str(work))\n+        assert env[\"GIT_WORK_TREE\"] == str(work.resolve())\n+\n+    def test_clears_index_file(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"GIT_INDEX_FILE\", \"/some/index\")\n+        shadow = tmp_path / \"shadow\"\n+        env = _git_env(shadow, str(tmp_path))\n+        assert \"GIT_INDEX_FILE\" not in env\n+\n+\n+# =========================================================================\n+# format_checkpoint_list\n+# =========================================================================\n+\n+class TestFormatCheckpointList:\n+    def test_empty_list(self):\n+        result = format_checkpoint_list([], \"/some/dir\")\n+        assert \"No checkpoints\" in result\n+\n+    def test_formats_entries(self):\n+        cps = [\n+            {\"hash\": \"abc123\", \"short_hash\": \"abc1\", \"timestamp\": \"2026-03-09T21:15:00-07:00\", \"reason\": \"before write_file\"},\n+            {\"hash\": \"def456\", \"short_hash\": \"def4\", \"timestamp\": \"2026-03-09T21:10:00-07:00\", \"reason\": \"before patch\"},\n+        ]\n+        result = format_checkpoint_list(cps, \"/home/user/project\")\n+        assert \"abc1\" in result\n+        assert \"def4\" in result\n+        assert \"before write_file\" in result\n+        assert \"/rollback\" in result\n+\n+\n+# =========================================================================\n+# File count guard\n+# =========================================================================\n+\n+class TestDirFileCount:\n+    def test_counts_files(self, work_dir):\n+        count = _dir_file_count(str(work_dir))\n+        assert count >= 2  # main.py + README.md\n+\n+    def test_nonexistent_dir(self, tmp_path):\n+        count = _dir_file_count(str(tmp_path / \"nonexistent\"))\n+        assert count == 0\n+\n+\n+# =========================================================================\n+# Error resilience\n+# =========================================================================\n+\n+class TestErrorResilience:\n+    def test_no_git_installed(self, work_dir, checkpoint_base, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        mgr = CheckpointManager(enabled=True)\n+        # Mock git not found\n+        monkeypatch.setattr(\"shutil.which\", lambda x: None)\n+        mgr._git_available = None  # reset lazy probe\n+        result = mgr.ensure_checkpoint(str(work_dir), \"test\")\n+        assert result is False\n+\n+    def test_checkpoint_failure_does_not_raise(self, mgr, work_dir, monkeypatch):\n+        \"\"\"Checkpoint failures should never raise — they're silently logged.\"\"\"\n+        def broken_run_git(*args, **kwargs):\n+            raise OSError(\"git exploded\")\n+        monkeypatch.setattr(\"tools.checkpoint_manager._run_git\", broken_run_git)\n+        # Should not raise\n+        result = mgr.ensure_checkpoint(str(work_dir), \"test\")\n+        assert result is False"]},{"file":"tools/checkpoint_manager.py","hunks":["@@ -0,0 +1,441 @@\n+\"\"\"\n+Checkpoint Manager — Transparent filesystem snapshots via shadow git repos.\n+\n+Creates automatic snapshots of working directories before file-mutating\n+operations (write_file, patch), triggered once per conversation turn.\n+Provides rollback to any previous checkpoint.\n+\n+This is NOT a tool — the LLM never sees it.  It's transparent infrastructure\n+controlled by the ``checkpoints`` config flag or ``--checkpoints`` CLI flag.\n+\n+Architecture:\n+    ~/.hermes/checkpoints/{sha256(abs_dir)[:16]}/   — shadow git repo\n+        HEAD, refs/, objects/                        — standard git internals\n+        HERMES_WORKDIR                               — original dir path\n+        info/exclude                                 — default excludes\n+\n+The shadow repo uses GIT_DIR + GIT_WORK_TREE so no git state leaks\n+into the user's project directory.\n+\"\"\"\n+\n+import hashlib\n+import logging\n+import os\n+import shutil\n+import subprocess\n+import time\n+from pathlib import Path\n+from typing import Dict, List, Optional, Set\n+\n+logger = logging.getLogger(__name__)\n+\n+# ---------------------------------------------------------------------------\n+# Constants\n+# ---------------------------------------------------------------------------\n+\n+CHECKPOINT_BASE = Path(os.getenv(\"HERMES_HOME\", Path.home() / \".hermes\")) / \"checkpoints\"\n+\n+DEFAULT_EXCLUDES = [\n+    \"node_modules/\",\n+    \"dist/\",\n+    \"build/\",\n+    \".env\",\n+    \".env.*\",\n+    \".env.local\",\n+    \".env.*.local\",\n+    \"__pycache__/\",\n+    \"*.pyc\",\n+    \"*.pyo\",\n+    \".DS_Store\",\n+    \"*.log\",\n+    \".cache/\",\n+    \".next/\",\n+    \".nuxt/\",\n+    \"coverage/\",\n+    \".pytest_cache/\",\n+    \".venv/\",\n+    \"venv/\",\n+    \".git/\",\n+]\n+\n+# Git subprocess timeout (seconds).\n+_GIT_TIMEOUT: int = max(10, min(60, int(os.getenv(\"HERMES_CHECKPOINT_TIMEOUT\", \"30\"))))\n+\n+# Max files to snapshot — skip huge directories to avoid slowdowns.\n+_MAX_FILES = 50_000\n+\n+\n+# ---------------------------------------------------------------------------\n+# Shadow repo helpers\n+# ---------------------------------------------------------------------------\n+\n+def _shadow_repo_path(working_dir: str) -> Path:\n+    \"\"\"Deterministic shadow repo path: sha256(abs_path)[:16].\"\"\"\n+    abs_path = str(Path(working_dir).resolve())\n+    dir_hash = hashlib.sha256(abs_path.encode()).hexdigest()[:16]\n+    return CHECKPOINT_BASE / dir_hash\n+\n+\n+def _git_env(shadow_repo: Path, working_dir: str) -> dict:\n+    \"\"\"Build env dict that redirects git to the shadow repo.\"\"\"\n+    env = os.environ.copy()\n+    env[\"GIT_DIR\"] = str(shadow_repo)\n+    env[\"GIT_WORK_TREE\"] = str(Path(working_dir).resolve())\n+    env.pop(\"GIT_INDEX_FILE\", None)\n+    env.pop(\"GIT_NAMESPACE\", None)\n+    env.pop(\"GIT_ALTERNATE_OBJECT_DIRECTORIES\", None)\n+    return env\n+\n+\n+def _run_git(\n+    args: List[str],\n+    shadow_repo: Path,\n+    working_dir: str,\n+    timeout: int = _GIT_TIMEOUT,\n+) -> tuple:\n+    \"\"\"Run a git command against the shadow repo.  Returns (ok, stdout, stderr).\"\"\"\n+    env = _git_env(shadow_repo, working_dir)\n+    try:\n+        result = subprocess.run(\n+            [\"git\"] + args,\n+            capture_output=True,\n+            text=True,\n+            timeout=timeout,\n+            env=env,\n+            cwd=str(Path(working_dir).resolve()),\n+        )\n+        return result.returncode == 0, result.stdout.strip(), result.stderr.strip()\n+    except subprocess.TimeoutExpired:\n+        return False, \"\", f\"git timed out after {timeout}s: git {' '.join(args)}\"\n+    except FileNotFoundError:\n+        return False, \"\", \"git not found\"\n+    except Exception as exc:\n+        return False, \"\", str(exc)\n+\n+\n+def _init_shadow_repo(shadow_repo: Path, working_dir: str) -> Optional[str]:\n+    \"\"\"Initialise shadow repo if needed.  Returns error string or None.\"\"\"\n+    if (shadow_repo / \"HEAD\").exists():\n+        return None\n+\n+    shadow_repo.mkdir(parents=True, exist_ok=True)\n+\n+    ok, _, err = _run_git([\"init\"], shadow_repo, working_dir)\n+    if not ok:\n+        return f\"Shadow repo init failed: {err}\"\n+\n+    _run_git([\"config\", \"user.email\", \"hermes@local\"], shadow_repo, working_dir)\n+    _run_git([\"config\", \"user.name\", \"Hermes Checkpoint\"], shadow_repo, working_dir)\n+\n+    info_dir = shadow_repo / \"info\"\n+    info_dir.mkdir(exist_ok=True)\n+    (info_dir / \"exclude\").write_text(\n+        \"\\n\".join(DEFAULT_EXCLUDES) + \"\\n\", encoding=\"utf-8\"\n+    )\n+\n+    (shadow_repo / \"HERMES_WORKDIR\").write_text(\n+        str(Path(working_dir).resolve()) + \"\\n\", encoding=\"utf-8\"\n+    )\n+\n+    logger.debug(\"Initialised checkpoint repo at %s for %s\", shadow_repo, working_dir)\n+    return None\n+\n+\n+def _dir_file_count(path: str) -> int:\n+    \"\"\"Quick file count estimate (stops early if over _MAX_FILES).\"\"\"\n+    count = 0\n+    try:\n+        for _ in Path(path).rglob(\"*\"):\n+            count += 1\n+            if count > _MAX_FILES:\n+                return count\n+    except (PermissionError, OSError):\n+        pass\n+    return count\n+\n+\n+# ---------------------------------------------------------------------------\n+# CheckpointManager\n+# ---------------------------------------------------------------------------\n+\n+class CheckpointManager:\n+    \"\"\"Manages automatic filesystem checkpoints.\n+\n+    Designed to be owned by AIAgent.  Call ``new_turn()`` at the start of\n+    each conversation turn and ``ensure_checkpoint(dir, reason)`` before\n+    any file-mutating tool call.  The manager deduplicates so at most one\n+    snapshot is taken per directory per turn.\n+\n+    Parameters\n+    ----------\n+    enabled : bool\n+        Master switch (from config / CLI flag).\n+    max_snapshots : int\n+        Keep at most this many checkpoints per directory.\n+    \"\"\"\n+\n+    def __init__(self, enabled: bool = False, max_snapshots: int = 50):\n+        self.enabled = enabled\n+        self.max_snapshots = max_snapshots\n+        self._checkpointed_dirs: Set[str] = set()\n+        self._git_available: Optional[bool] = None  # lazy probe\n+\n+    # ------------------------------------------------------------------\n+    # Turn lifecycle\n+    # ------------------------------------------------------------------\n+\n+    def new_turn(self) -> None:\n+        \"\"\"Reset per-turn dedup.  Call at the start of each agent iteration.\"\"\"\n+        self._checkpointed_dirs.clear()\n+\n+    # ------------------------------------------------------------------\n+    # Public API\n+    # ------------------------------------------------------------------\n+\n+    def ensure_checkpoint(self, working_dir: str, reason: str = \"auto\") -> bool:\n+        \"\"\"Take a checkpoint if enabled and not already done this turn.\n+\n+        Returns True if a checkpoint was taken, False otherwise.\n+        Never raises — all errors are silently logged.\n+        \"\"\"\n+        if not self.enabled:\n+            return False\n+\n+        # Lazy git probe\n+        if self._git_available is None:\n+            self._git_available = shutil.which(\"git\") is not None\n+            if not self._git_available:\n+                logger.debug(\"Checkpoints disabled: git not found\")\n+        if not self._git_available:\n+            return False\n+\n+        abs_dir = str(Path(working_dir).resolve())\n+\n+        # Skip root, home, and other overly broad directories\n+        if abs_dir in (\"/\", str(Path.home())):\n+            logger.debug(\"Checkpoint skipped: directory too broad (%s)\", abs_dir)\n+            return False\n+\n+        # Already checkpointed this turn?\n+        if abs_dir in self._checkpointed_dirs:\n+            return False\n+\n+        self._checkpointed_dirs.add(abs_dir)\n+\n+        try:\n+            return self._take(abs_dir, reason)\n+        except Exception as e:\n+            logger.debug(\"Checkpoint failed (non-fatal): %s\", e)\n+            return False\n+\n+    def list_checkpoints(self, working_dir: str) -> List[Dict]:\n+        \"\"\"List available checkpoints for a directory.\n+\n+        Returns a list of dicts with keys: hash, short_hash, timestamp, reason.\n+        Most recent first.\n+        \"\"\"\n+        abs_dir = str(Path(working_dir).resolve())\n+        shadow = _shadow_repo_path(abs_dir)\n+\n+        if not (shadow / \"HEAD\").exists():\n+            return []\n+\n+        ok, stdout, _ = _run_git(\n+            [\"log\", \"--format=%H|%h|%aI|%s\", \"--no-walk=unsorted\",\n+             \"--all\" if False else \"HEAD\",  # just HEAD lineage\n+             \"-n\", str(self.max_snapshots)],\n+            shadow, abs_dir,\n+        )\n+\n+        # Simpler: just use regular log\n+        ok, stdout, _ = _run_git(\n+            [\"log\", \"--format=%H|%h|%aI|%s\", \"-n\", str(self.max_snapshots)],\n+            shadow, abs_dir,\n+        )\n+\n+        if not ok or not stdout:\n+            return []\n+\n+        results = []\n+        for line in stdout.splitlines():\n+            parts = line.split(\"|\", 3)\n+            if len(parts) == 4:\n+                results.append({\n+                    \"hash\": parts[0],\n+                    \"short_hash\": parts[1],\n+                    \"timestamp\": parts[2],\n+                    \"reason\": parts[3],\n+                })\n+        return results\n+\n+    def restore(self, working_dir: str, commit_hash: str) -> Dict:\n+        \"\"\"Restore files to a checkpoint state.\n+\n+        Uses ``git checkout <hash> -- .`` which restores tracked files\n+        without moving HEAD — safe and reversible.\n+\n+        Returns dict with success/error info.\n+        \"\"\"\n+        abs_dir = str(Path(working_dir).resolve())\n+        shadow = _shadow_repo_path(abs_dir)\n+\n+        if not (shadow / \"HEAD\").exists():\n+            return {\"success\": False, \"error\": \"No checkpoints exist for this directory\"}\n+\n+        # Verify the commit exists\n+        ok, _, err = _run_git(\n+            [\"cat-file\", \"-t\", commit_hash], shadow, abs_dir,\n+        )\n+        if not ok:\n+            return {\"success\": False, \"error\": f\"Checkpoint '{commit_hash}' not found\"}\n+\n+        # Take a checkpoint of current state before restoring (so you can undo the undo)\n+        self._take(abs_dir, f\"pre-rollback snapshot (restoring to {commit_hash[:8]})\")\n+\n+        # Restore\n+        ok, stdout, err = _run_git(\n+            [\"checkout\", commit_hash, \"--\", \".\"],\n+            shadow, abs_dir, timeout=_GIT_TIMEOUT * 2,\n+        )\n+\n+        if not ok:\n+            return {\"success\": False, \"error\": f\"Restore failed: {err}\"}\n+\n+        # Get info about what was restored\n+        ok2, reason_out, _ = _run_git(\n+            [\"log\", \"--format=%s\", \"-1\", commit_hash], shadow, abs_dir,\n+        )\n+        reason = reason_out if ok2 else \"unknown\"\n+\n+        return {\n+            \"success\": True,\n+            \"restored_to\": commit_hash[:8],\n+            \"reason\": reason,\n+            \"directory\": abs_dir,\n+        }\n+\n+    def get_working_dir_for_path(self, file_path: str) -> str:\n+        \"\"\"Resolve a file path to its working directory for checkpointing.\n+\n+        Walks up from the file's parent to find a reasonable project root\n+        (directory containing .git, pyproject.toml, package.json, etc.).\n+        Falls back to the file's parent directory.\n+        \"\"\"\n+        path = Path(file_path).resolve()\n+        if path.is_dir():\n+            candidate = path\n+        else:\n+            candidate = path.parent\n+\n+        # Walk up looking for project root markers\n+        markers = {\".git\", \"pyproject.toml\", \"package.json\", \"Cargo.toml\",\n+                    \"go.mod\", \"Makefile\", \"pom.xml\", \".hg\", \"Gemfile\"}\n+        check = candidate\n+        while check != check.parent:\n+            if any((check / m).exists() for m in markers):\n+                return str(check)\n+            check = check.parent\n+\n+        # No project root found — use the file's parent\n+        return str(candidate)\n+\n+    # ------------------------------------------------------------------\n+    # Internal\n+    # ------------------------------------------------------------------\n+\n+    def _take(self, working_dir: str, reason: str) -> bool:\n+        \"\"\"Take a snapshot.  Returns True on success.\"\"\"\n+        shadow = _shadow_repo_path(working_dir)\n+\n+        # Init if needed\n+        err = _init_shadow_repo(shadow, working_dir)\n+        if err:\n+            logger.debug(\"Checkpoint init failed: %s\", err)\n+            return False\n+\n+        # Quick size guard — don't try to snapshot enormous directories\n+        if _dir_file_count(working_dir) > _MAX_FILES:\n+            logger.debug(\"Checkpoint skipped: >%d files in %s\", _MAX_FILES, working_dir)\n+            return False\n+\n+        # Stage everything\n+        ok, _, err = _run_git(\n+            [\"add\", \"-A\"], shadow, working_dir, timeout=_GIT_TIMEOUT * 2,\n+        )\n+        if not ok:\n+            logger.debug(\"Checkpoint git-add failed: %s\", err)\n+            return False\n+\n+        # Check if there's anything to commit\n+        ok_diff, diff_out, _ = _run_git(\n+            [\"diff\", \"--cached\", \"--quiet\"], shadow, working_dir,\n+        )\n+        if ok_diff:\n+            # No changes to commit\n+            logger.debug(\"Checkpoint skipped: no changes in %s\", working_dir)\n+            return False\n+\n+        # Commit\n+        ok, _, err = _run_git(\n+            [\"commit\", \"-m\", reason, \"--allow-empty-message\"],\n+            shadow, working_dir, timeout=_GIT_TIMEOUT * 2,\n+        )\n+        if not ok:\n+            logger.debug(\"Checkpoint commit failed: %s\", err)\n+            return False\n+\n+        logger.debug(\"Checkpoint taken in %s: %s\", working_dir, reason)\n+\n+        # Prune old snapshots\n+        self._prune(shadow, working_dir)\n+\n+        return True\n+\n+    def _prune(self, shadow_repo: Path, working_dir: str) -> None:\n+        \"\"\"Keep only the last max_snapshots commits via orphan reset.\"\"\"\n+        ok, stdout, _ = _run_git(\n+            [\"rev-list\", \"--count\", \"HEAD\"], shadow_repo, working_dir,\n+        )\n+        if not ok:\n+            return\n+\n+        try:\n+            count = int(stdout)\n+        except ValueError:\n+            return\n+\n+        if count <= self.max_snapshots:\n+            return\n+\n+        # Get the hash of the commit at the cutoff point\n+        ok, cutoff_hash, _ = _run_git(\n+            [\"rev-list\", \"--reverse\", \"HEAD\", \"--skip=0\",\n+             f\"--max-count=1\"],\n+            shadow_repo, working_dir,\n+        )\n+\n+        # For simplicity, we don't actually prune — git's pack mechanism\n+        # handles this efficiently, and the objects are small.  The log\n+        # listing is already limited by max_snapshots.\n+        # Full pruning would require rebase --onto or filter-branch which\n+        # is fragile for a background feature.  We just limit the log view.\n+        logger.debug(\"Checkpoint repo has %d commits (limit %d)\", count, self.max_snapshots)\n+\n+\n+def format_checkpoint_list(checkpoints: List[Dict], directory: str) -> str:\n+    \"\"\"Format checkpoint list for display to user.\"\"\"\n+    if not checkpoints:\n+        return f\"No checkpoints found for {directory}\"\n+\n+    lines = [f\"📸 Checkpoints for {directory}:\\n\"]\n+    for i, cp in enumerate(checkpoints, 1):\n+        # Parse ISO timestamp to something readable\n+        ts = cp[\"timestamp\"]\n+        if \"T\" in ts:\n+            ts = ts.split(\"T\")[1].split(\"+\")[0].split(\"-\")[0][:5]  # HH:MM\n+            date = cp[\"timestamp\"].split(\"T\")[0]\n+            ts = f\"{date} {ts}\"\n+        lines.append(f\"  {i}. {cp['short_hash']}  {ts}  {cp['reason']}\")\n+\n+    lines.append(f\"\\nUse /rollback <number> to restore, e.g. /rollback 1\")\n+    return \"\\n\".join(lines)"]},{"file":"website/docs/reference/cli-commands.md","hunks":["@@ -24,6 +24,7 @@ These are commands you run from your shell.\n | `hermes chat --toolsets \"web,terminal\"` / `-t` | Use specific toolsets |\n | `hermes chat --verbose` | Enable verbose/debug output |\n | `hermes --worktree` / `-w` | Start in an isolated git worktree (for parallel agents) |\n+| `hermes --checkpoints` | Enable filesystem checkpoints before destructive file operations |\n \n ### Provider & Model Management\n ","@@ -202,6 +203,8 @@ These work in messaging platforms (Telegram, Discord, Slack, WhatsApp) but not t\n | `/sethome` | Set this chat as the home channel |\n | `/status` | Show session info |\n | `/reload-mcp` | Reload MCP servers from config |\n+| `/rollback` | List filesystem checkpoints for the current directory |\n+| `/rollback <N>` | Restore files to checkpoint #N |\n | `/update` | Update Hermes Agent to the latest version |\n \n ---"]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -663,6 +663,16 @@ browser:\n   record_sessions: false         # Auto-record browser sessions as WebM videos to ~/.hermes/browser_recordings/\n ```\n \n+## Checkpoints\n+\n+Automatic filesystem snapshots before destructive file operations. See the [Checkpoints feature page](/docs/user-guide/features/checkpoints) for details.\n+\n+```yaml\n+checkpoints:\n+  enabled: false                 # Enable automatic checkpoints (also: hermes --checkpoints)\n+  max_snapshots: 50              # Max checkpoints to keep per directory\n+```\n+\n ## Delegation\n \n Configure subagent behavior for the delegate tool:"]},{"file":"website/docs/user-guide/features/checkpoints.md","hunks":["@@ -0,0 +1,97 @@\n+# Filesystem Checkpoints\n+\n+Hermes can automatically snapshot your working directory before making file changes, giving you a safety net to roll back if something goes wrong.\n+\n+## How It Works\n+\n+When enabled, Hermes takes a **one-time snapshot** at the start of each conversation turn before the first file-modifying operation (`write_file` or `patch`). This creates a point-in-time backup you can restore to at any time.\n+\n+Under the hood, checkpoints use a **shadow git repository** stored at `~/.hermes/checkpoints/`. This is completely separate from your project's git — no `.git` directory is created in your project, and your own git history is never touched.\n+\n+## Enabling Checkpoints\n+\n+### Per-session (CLI flag)\n+\n+```bash\n+hermes --checkpoints\n+```\n+\n+### Permanently (config.yaml)\n+\n+```yaml\n+# ~/.hermes/config.yaml\n+checkpoints:\n+  enabled: true\n+  max_snapshots: 50  # max checkpoints per directory (default: 50)\n+```\n+\n+## Rolling Back\n+\n+Use the `/rollback` slash command:\n+\n+```\n+/rollback          # List all available checkpoints\n+/rollback 1        # Restore to checkpoint #1 (most recent)\n+/rollback 3        # Restore to checkpoint #3 (further back)\n+/rollback abc1234  # Restore by git commit hash\n+```\n+\n+Example output:\n+\n+```\n+📸 Checkpoints for /home/user/project:\n+\n+  1. abc1234  2026-03-10 14:22  before write_file\n+  2. def5678  2026-03-10 14:15  before patch\n+  3. ghi9012  2026-03-10 14:08  before write_file\n+\n+Use /rollback <number> to restore, e.g. /rollback 1\n+```\n+\n+When you restore, Hermes automatically takes a **pre-rollback snapshot** first — so you can always undo your undo.\n+\n+## What Gets Checkpointed\n+\n+Checkpoints capture the entire working directory (the project root), excluding common large/sensitive patterns:\n+\n+- `node_modules/`, `dist/`, `build/`\n+- `.env`, `.env.*`\n+- `__pycache__/`, `*.pyc`\n+- `.venv/`, `venv/`\n+- `.git/`\n+- `.DS_Store`, `*.log`\n+\n+## Performance\n+\n+Checkpoints are designed to be lightweight:\n+\n+- **Once per turn** — only the first file operation triggers a snapshot, not every write\n+- **Skips large directories** — directories with >50,000 files are skipped automatically\n+- **Skips when nothing changed** — if no files were modified since the last checkpoint, no commit is created\n+- **Non-blocking** — if a checkpoint fails for any reason, the file operation proceeds normally\n+\n+## How It Determines the Project Root\n+\n+When you write to a file like `src/components/Button.tsx`, Hermes walks up the directory tree looking for project markers (`.git`, `pyproject.toml`, `package.json`, `Cargo.toml`, etc.) to find the project root. This ensures the entire project is checkpointed, not just the file's parent directory.\n+\n+## Platforms\n+\n+Checkpoints work on both:\n+- **CLI** — uses your current working directory\n+- **Gateway** (Telegram, Discord, etc.) — uses `MESSAGING_CWD`\n+\n+The `/rollback` command is available on all platforms.\n+\n+## FAQ\n+\n+**Does this conflict with my project's git?**\n+No. Checkpoints use a completely separate shadow git repository via `GIT_DIR` environment variables. Your project's `.git/` is never touched.\n+\n+**How much disk space do checkpoints use?**\n+Git is very efficient at storing diffs. For most projects, checkpoint data is negligible. Old checkpoints are pruned when `max_snapshots` is exceeded.\n+\n+**Can I checkpoint without git installed?**\n+No — git must be available on your PATH. If it's not installed, checkpoints silently disable.\n+\n+**Can I roll back across sessions?**\n+Yes! Checkpoints persist in `~/.hermes/checkpoints/` and survive across sessions. You can roll back to a checkpoint from yesterday.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}