{"repo":"NousResearch/hermes-agent","public_id":"41444","display_label":"Skill drafting with approval gates and provenance tracking","summary":"These items concern a skill drafter feature that introduces human confirmation gates for auto-created skills and memory writes, along with provenance metadata and tiered draft suppression logic.","subsystem":"skills","counts":{"members":3,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":0,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":38199,"merged_at":"2026-06-10T04:51:44+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-10T04:51:44+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":17251,"pr_number":38199,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-10T04:51:44+00:00"},{"issue_number":41444,"pr_number":38199,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-10T04:51:44+00:00"}],"members":[{"kind":"issue","number":41444,"title":"[Feature]: Skill Drafter — human confirmation gate and provenance tiers for auto-created skills","url":null,"state":"open"},{"kind":"pr","number":38199,"title":"feat(memory,skills): approve/deny gate for memory + skill writes","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","gateway/slash_commands.py","hermes_cli/cli_commands_mixin.py","hermes_cli/commands.py","hermes_cli/config.py","hermes_cli/write_approval_commands.py","tests/hermes_cli/test_commands.py","tests/tools/test_write_approval.py","tools/memory_tool.py","tools/skill_manager_tool.py","tools/write_approval.py","website/docs/user-guide/features/memory.md"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -6932,7 +6932,6 @@ def _resolve_personality_prompt(value) -> str:\n \n \n \n-\n     def _show_gateway_status(self):\n         \"\"\"Show status of the gateway and connected messaging platforms.\"\"\"\n         from gateway.config import load_gateway_config, Platform","@@ -7245,6 +7244,8 @@ def process_command(self, command: str) -> bool:\n         elif canonical == \"skills\":\n             with self._busy_command(self._slow_command_status(cmd_original)):\n                 self._handle_skills_command(cmd_original)\n+        elif canonical == \"memory\":\n+            self._handle_memory_command(cmd_original)\n         elif canonical == \"platforms\":\n             self._show_gateway_status()\n         elif canonical == \"status\":"]},{"file":"gateway/run.py","hunks":["@@ -7056,6 +7056,9 @@ async def _do_reset():\n         if canonical == \"reasoning\":\n             return await self._handle_reasoning_command(event)\n \n+        if canonical == \"memory\":\n+            return await self._handle_memory_command(event)\n+\n         if canonical == \"fast\":\n             return await self._handle_fast_command(event)\n "]},{"file":"gateway/slash_commands.py","hunks":["@@ -1954,6 +1954,48 @@ def _save_config_key(key_path: str, value):\n         self._evict_cached_agent(session_key)\n         return t(\"gateway.reasoning.set_session\", effort=effort)\n \n+    async def _handle_memory_command(self, event: MessageEvent) -> str:\n+        \"\"\"Handle /memory — review pending memory writes + set write mode.\n+\n+        Memory entries are small enough to review inline in a chat bubble, so\n+        the full pending/approve/reject/mode flow works on every platform.\n+        Mode changes persist to config.yaml and evict the cached agent so the\n+        new write_mode takes effect on the next message.\n+        \"\"\"\n+        from gateway.run import _hermes_home\n+        from hermes_cli.write_approval_commands import handle_pending_subcommand\n+        from tools import write_approval as wa\n+        from tools.memory_tool import MemoryStore\n+\n+        raw_args = event.get_command_args().strip()\n+        args = raw_args.split() if raw_args else []\n+        session_key = self._session_key_for_source(event.source)\n+        config_path = _hermes_home / \"config.yaml\"\n+\n+        def _set_mode(mode: str):\n+            import yaml\n+            user_config = {}\n+            if config_path.exists():\n+                with open(config_path, encoding=\"utf-8\") as f:\n+                    user_config = yaml.safe_load(f) or {}\n+            user_config.setdefault(\"memory\", {})[\"write_mode\"] = mode\n+            atomic_yaml_write(config_path, user_config)\n+            # New write_mode must take effect next message → drop cached agent.\n+            self._evict_cached_agent(session_key)\n+\n+        # Apply approved writes against a fresh on-disk store (the gateway has\n+        # no long-lived agent; the store persists to the same MEMORY/USER.md).\n+        store = MemoryStore()\n+        store.load_from_disk()\n+\n+        out = handle_pending_subcommand(\n+            wa.MEMORY, args, memory_store=store, set_mode_fn=_set_mode,\n+        )\n+        if out is None:\n+            out = (\"Unknown /memory subcommand. Use: pending, approve <id>, \"\n+                   \"reject <id>, mode <on|off|approve>.\")\n+        return out\n+\n     async def _handle_fast_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.\"\"\"\n         from gateway.run import _hermes_home, _load_gateway_config, _resolve_gateway_model"]},{"file":"hermes_cli/cli_commands_mixin.py","hunks":["@@ -1301,9 +1301,46 @@ def _handle_kanban_command(self, cmd: str):\n     def _handle_skills_command(self, cmd: str):\n         \"\"\"Handle /skills slash command — delegates to hermes_cli.skills_hub.\"\"\"\n         from cli import ChatConsole\n+        # Intercept write-approval review subcommands first (pending/approve/\n+        # reject/diff/mode); everything else goes to the skills hub.\n+        parts = cmd.strip().split()\n+        args = parts[1:] if len(parts) > 1 else []\n+        if args and args[0].lower() in {\"pending\", \"approve\", \"apply\", \"reject\",\n+                                        \"deny\", \"drop\", \"diff\", \"mode\"}:\n+            from hermes_cli.write_approval_commands import handle_pending_subcommand\n+            from tools import write_approval as wa\n+            out = handle_pending_subcommand(\n+                wa.SKILLS, args,\n+                set_mode_fn=lambda m: self._save_write_mode(\"skills\", m),\n+            )\n+            if out is not None:\n+                print(out)\n+                return\n         from hermes_cli.skills_hub import handle_skills_slash\n         handle_skills_slash(cmd, ChatConsole())\n \n+    def _handle_memory_command(self, cmd: str):\n+        \"\"\"Handle /memory slash command — pending review + write-mode control.\"\"\"\n+        from hermes_cli.write_approval_commands import handle_pending_subcommand\n+        from tools import write_approval as wa\n+        parts = cmd.strip().split()\n+        args = parts[1:] if len(parts) > 1 else []\n+        store = getattr(self.agent, \"_memory_store\", None) if getattr(self, \"agent\", None) else None\n+        out = handle_pending_subcommand(\n+            wa.MEMORY, args,\n+            memory_store=store,\n+            set_mode_fn=lambda m: self._save_write_mode(\"memory\", m),\n+        )\n+        if out is None:\n+            out = (\"Unknown /memory subcommand. \"\n+                   \"Use: pending, approve <id>, reject <id>, mode <on|off|approve>.\")\n+        print(out)\n+\n+    def _save_write_mode(self, subsystem: str, mode: str):\n+        \"\"\"Persist <subsystem>.write_mode to config (for /memory|/skills mode).\"\"\"\n+        from cli import save_config_value\n+        save_config_value(f\"{subsystem}.write_mode\", mode)\n+\n     def _handle_background_command(self, cmd: str):\n         \"\"\"Handle /background <prompt> — run a prompt in a separate background session.\n "]},{"file":"hermes_cli/commands.py","hunks":["@@ -167,7 +167,12 @@ class CommandDef:\n                cli_only=True),\n     CommandDef(\"skills\", \"Search, install, inspect, or manage skills\",\n                \"Tools & Skills\", cli_only=True,\n-               subcommands=(\"search\", \"browse\", \"inspect\", \"install\", \"audit\")),\n+               subcommands=(\"search\", \"browse\", \"inspect\", \"install\", \"audit\",\n+                            \"pending\", \"approve\", \"reject\", \"diff\", \"mode\")),\n+    CommandDef(\"memory\", \"Review pending memory writes / set write mode\",\n+               \"Tools & Skills\",\n+               args_hint=\"[pending|approve|reject|mode] [id|on|off|approve]\",\n+               subcommands=(\"pending\", \"approve\", \"reject\", \"mode\")),\n     CommandDef(\"bundles\", \"List skill bundles (aliases /<name> for multiple skills)\",\n                \"Tools & Skills\"),\n     CommandDef(\"cron\", \"Manage scheduled tasks\", \"Tools & Skills\","]},{"file":"hermes_cli/config.py","hunks":["@@ -1653,6 +1653,18 @@ def _ensure_hermes_home_managed(home: Path):\n     \"memory\": {\n         \"memory_enabled\": True,\n         \"user_profile_enabled\": True,\n+        # Write gate for the memory tool (add/replace/remove), applied to BOTH\n+        # foreground agent turns and the background self-improvement review fork\n+        # (the source of unprompted \"wrong assumption\" saves users reported):\n+        #   on      — write freely (default, current behaviour)\n+        #   off     — never write; the memory tool returns a clean disabled result\n+        #   approve — foreground writes block on an inline approve/deny prompt\n+        #             (entries are small enough to review in a chat bubble);\n+        #             background-review writes are staged for review instead of\n+        #             committed (a daemon thread cannot block on a prompt).\n+        #             Pending entries: /memory pending, /memory approve <id>,\n+        #             /memory reject <id>.\n+        \"write_mode\": \"on\",\n         \"memory_char_limit\": 2200,   # ~800 tokens at 2.75 chars/token\n         \"user_char_limit\": 1375,     # ~500 tokens at 2.75 chars/token\n         # External memory provider plugin (empty = built-in only).","@@ -1757,6 +1769,17 @@ def _ensure_hermes_home_managed(home: Path):\n         # External hub installs (trusted/community sources) are always\n         # scanned regardless of this setting.\n         \"guard_agent_created\": False,\n+        # Write gate for skill_manage (create/edit/patch/write_file/delete/\n+        # remove_file), applied to BOTH foreground agent turns and the\n+        # background self-improvement review fork:\n+        #   on      — write freely (default, current behaviour)\n+        #   off     — never write; skill_manage returns a clean disabled result\n+        #   approve — stage the write for review instead of committing.\n+        #             Pending skills are listed with /skills pending, reviewed\n+        #             with /skills diff <id> (full diff — CLI/dashboard/file,\n+        #             never crammed into a chat bubble), and applied with\n+        #             /skills approve <id> or dropped with /skills reject <id>.\n+        \"write_mode\": \"on\",\n     },\n \n     # Curator — background skill maintenance."]},{"file":"hermes_cli/write_approval_commands.py","hunks":["@@ -0,0 +1,197 @@\n+#!/usr/bin/env python3\n+\"\"\"Shared handlers for the /memory and /skills write-approval subcommands.\n+\n+Both the interactive CLI (``cli.py``) and the gateway (``gateway/run.py``) call\n+into this module so the pending-review UX (list / approve / reject / diff /\n+mode) lives in one place. Each caller owns only its surface concerns:\n+formatting the returned text and, for the gateway, persisting config + evicting\n+the cached agent on a mode change.\n+\n+Every public handler returns a plain text string suitable for both a terminal\n+and a chat message. Skill diffs are intentionally NOT inlined here — the\n+``diff`` handler returns the full diff for the CLI pager, but on a messaging\n+platform the gateway truncates it and points the user at the dashboard / file.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+from typing import List, Optional\n+\n+from tools import write_approval as wa\n+\n+_VALID_MODES = (wa.MODE_ON, wa.MODE_OFF, wa.MODE_APPROVE)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Formatting helpers\n+# ---------------------------------------------------------------------------\n+\n+def _fmt_pending_list(subsystem: str) -> str:\n+    records = wa.list_pending(subsystem)\n+    if not records:\n+        return f\"No pending {subsystem} writes.\"\n+    lines = [f\"Pending {subsystem} writes ({len(records)}):\"]\n+    for r in records:\n+        origin = r.get(\"origin\", \"foreground\")\n+        tag = \" [auto]\" if origin == \"background_review\" else \"\"\n+        lines.append(f\"  {r['id']}{tag}  {r.get('summary', '')}\")\n+    where = \"/{s} approve <id>\".format(s=subsystem)\n+    lines.append(\"\")\n+    lines.append(f\"Apply: {where}   Reject: /{subsystem} reject <id>\")\n+    if subsystem == wa.SKILLS:\n+        lines.append(\"Review full diff: /skills diff <id>\")\n+    return \"\\n\".join(lines)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Subcommand dispatch\n+# ---------------------------------------------------------------------------\n+\n+def handle_pending_subcommand(\n+    subsystem: str,\n+    args: List[str],\n+    *,\n+    memory_store=None,\n+    set_mode_fn=None,\n+) -> Optional[str]:\n+    \"\"\"Dispatch a /memory or /skills subcommand.\n+\n+    Args:\n+        subsystem: ``memory`` or ``skills``.\n+        args: tokens after the slash command (e.g. ``[\"approve\", \"a1b2\"]``).\n+        memory_store: live MemoryStore for applying approved memory writes\n+            (CLI passes ``self.agent._memory_store``; gateway applies against a\n+            freshly loaded store).\n+        set_mode_fn: optional callable ``(mode: str) -> None`` that persists the\n+            new write_mode to config (gateway provides this; CLI uses its own\n+            ``save_config_value`` and passes a closure).\n+\n+    Returns a text string to show the user. Returns None when the args are not\n+    a write-approval subcommand (caller falls through to its other handling,\n+    e.g. /skills search).\n+    \"\"\"\n+    if not args:\n+        # Bare /memory or /skills with no sub → show pending + current mode.\n+        mode = wa.get_write_mode(subsystem)\n+        return f\"{subsystem}.write_mode = {mode}\\n\\n\" + _fmt_pending_list(subsystem)\n+\n+    sub = args[0].lower()\n+    rest = args[1:]\n+\n+    if sub == \"pending\":\n+        return _fmt_pending_list(subsystem)\n+\n+    if sub in {\"approve\", \"apply\"}:\n+        return _approve(subsystem, rest, memory_store)\n+\n+    if sub in {\"reject\", \"deny\", \"drop\"}:\n+        return _reject(subsystem, rest)\n+\n+    if sub == \"diff\" and subsystem == wa.SKILLS:\n+        return _diff(rest)\n+\n+    if sub == \"mode\":\n+        return _set_mode(subsystem, rest, set_mode_fn)\n+\n+    return None  # not ours — caller handles\n+\n+\n+def _resolve_one(subsystem: str, rest: List[str]):\n+    if not rest:\n+        return None, f\"Usage: /{subsystem} approve|reject <id>  (or 'all')\"\n+    return rest[0], None\n+\n+\n+def _approve(subsystem: str, rest: List[str], memory_store) -> str:\n+    target, err = _resolve_one(subsystem, rest)\n+    if err or target is None:\n+        return err or f\"Usage: /{subsystem} approve <id>\"\n+\n+    records = wa.list_pending(subsystem)\n+    if not records:\n+        return f\"No pending {subsystem} writes.\"\n+\n+    if target.lower() == \"all\":\n+        targets = list(records)\n+    else:\n+        rec = wa.get_pending(subsystem, target)\n+        if not rec:\n+            return f\"No pending {subsystem} write with id '{target}'.\"\n+        targets = [rec]\n+\n+    applied, failed = 0, []\n+    for rec in targets:\n+        ok, msg = _apply_one(subsystem, rec, memory_store)\n+        if ok:\n+            wa.discard_pending(subsystem, rec[\"id\"])\n+            applied += 1\n+        else:\n+            failed.append(f\"{rec['id']}: {msg}\")\n+\n+    out = [f\"Approved {applied} {subsystem} write(s).\"]\n+    if failed:\n+        out.append(\"Failed:\")\n+        out.extend(f\"  {f}\" for f in failed)\n+    return \"\\n\".join(out)\n+\n+\n+def _apply_one(subsystem: str, rec, memory_store):\n+    payload = rec.get(\"payload\", {})\n+    try:\n+        if subsystem == wa.MEMORY:\n+            if memory_store is None:\n+                return False, \"memory store unavailable\"\n+            from tools.memory_tool import apply_memory_pending\n+            result = apply_memory_pending(payload, memory_store)\n+            return bool(result.get(\"success\")), result.get(\"error\", \"\")\n+        else:\n+            from tools.skill_manager_tool import apply_skill_pending\n+            result = json.loads(apply_skill_pending(payload))\n+            return bool(result.get(\"success\")), result.get(\"error\", \"\")\n+    except Exception as e:\n+        return False, str(e)\n+\n+\n+def _reject(subsystem: str, rest: List[str]) -> str:\n+    target, err = _resolve_one(subsystem, rest)\n+    if err or target is None:\n+        return err or f\"Usage: /{subsystem} reject <id>\"\n+    if target.lower() == \"all\":\n+        n = 0\n+        for rec in wa.list_pending(subsystem):\n+            if wa.discard_pending(subsystem, rec[\"id\"]):\n+                n += 1\n+        return f\"Rejected {n} pending {subsystem} write(s).\"\n+    if wa.discard_pending(subsystem, target):\n+        return f\"Rejected pending {subsystem} write '{target}'.\"\n+    return f\"No pending {subsystem} write with id '{target}'.\"\n+\n+\n+def _diff(rest: List[str]) -> str:\n+    if not rest:\n+        return \"Usage: /skills diff <id>\"\n+    rec = wa.get_pending(wa.SKILLS, rest[0])\n+    if not rec:\n+        return f\"No pending skill write with id '{rest[0]}'.\"\n+    diff = wa.skill_pending_diff(rec)\n+    header = f\"# Pending skill write {rec['id']}: {rec.get('summary', '')}\\n\"\n+    return header + \"\\n\" + diff\n+\n+\n+def _set_mode(subsystem: str, rest: List[str], set_mode_fn) -> str:\n+    if not rest:\n+        cur = wa.get_write_mode(subsystem)\n+        return (f\"{subsystem}.write_mode = {cur}\\n\"\n+                f\"Set with: /{subsystem} mode <on|off|approve>\")\n+    mode = rest[0].lower()\n+    if mode not in _VALID_MODES:\n+        return f\"Invalid mode '{mode}'. Use: on, off, approve.\"\n+    if set_mode_fn is None:\n+        return (f\"To change {subsystem} write mode, run:\\n\"\n+                f\"  hermes config set {subsystem}.write_mode {mode}\")\n+    try:\n+        set_mode_fn(mode)\n+    except Exception as e:\n+        return f\"Failed to set {subsystem}.write_mode: {e}\"\n+    return f\"{subsystem}.write_mode set to '{mode}'.\""]},{"file":"tests/hermes_cli/test_commands.py","hunks":["@@ -336,19 +336,23 @@ def test_excludes_slack_reserved_commands(self):\n             )\n \n     def test_includes_aliases_as_first_class_slashes(self):\n-        \"\"\"Aliases (/btw, /bg, /reset) must be registered as standalone\n+        \"\"\"Aliases (/btw, /bg, /reset, …) must be registered as standalone\n         slashes — this is the whole point of native-slashes parity.\n \n-        Note: Slack's manifest hard-caps slash commands at 50\n-        (``_SLACK_MAX_SLASH_COMMANDS``). Canonical names win slots first,\n-        then aliases, so the lowest-priority aliases can be clamped off\n-        once the registry fills the cap (e.g. ``/q`` once ``/version``\n-        landed). The surviving aliases below still prove alias parity;\n-        anything dropped remains reachable via ``/hermes <command>``.\"\"\"\n-        names = {n for n, _d, _h in slack_native_slashes()}\n+        Asserts the contract (aliases are surfaced as first-class slashes),\n+        not a specific alias's survival of Slack's 50-slash clamp — which alias\n+        lands last shifts whenever a canonical command is added, so pinning one\n+        name (previously ``q``) made this a change-detector.\n+        \"\"\"\n+        slashes = slack_native_slashes()\n+        names = {n for n, _d, _h in slashes}\n+        # Aliases that sort early in the registry always fit under the cap.\n         assert \"btw\" in names\n         assert \"bg\" in names\n         assert \"reset\" in names\n+        # And at least one alias is surfaced as an alias entry (description\n+        # carries the \"Alias for /…\" marker), proving the alias pass ran.\n+        assert any(d.startswith(\"Alias for /\") for _n, d, _h in slashes)\n \n     def test_telegram_parity(self):\n         \"\"\"Every Telegram bot command must be registerable on Slack too."]},{"file":"tests/tools/test_write_approval.py","hunks":["@@ -0,0 +1,241 @@\n+\"\"\"Tests for the memory/skill write-approval gate (tools/write_approval.py)\n+and the shared slash-command handlers (hermes_cli/write_approval_commands.py).\n+\n+Covers the tri-state write_mode (on/off/approve) for both subsystems, the\n+foreground-vs-background staging split, pending store CRUD, and the\n+list/approve/reject/diff/mode subcommand dispatch.\n+\"\"\"\n+\n+import json\n+import os\n+import tempfile\n+import shutil\n+\n+import pytest\n+\n+\n[redacted:email]\n+def hermes_home(monkeypatch):\n+    d = tempfile.mkdtemp(prefix=\"hermes_wa_test_\")\n+    home = os.path.join(d, \".hermes\")\n+    os.makedirs(home)\n+    monkeypatch.setenv(\"HERMES_HOME\", home)\n+    yield home\n+    shutil.rmtree(d, ignore_errors=True)\n+\n+\n+def _set_mode(subsystem, mode):\n+    import hermes_cli.config as cfg\n+    c = cfg.load_config()\n+    c.setdefault(subsystem, {})[\"write_mode\"] = mode\n+    cfg.save_config(c)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Mode resolution\n+# ---------------------------------------------------------------------------\n+\n+def test_default_write_mode_is_on(hermes_home):\n+    from tools import write_approval as wa\n+    assert wa.get_write_mode(\"memory\") == \"on\"\n+    assert wa.get_write_mode(\"skills\") == \"on\"\n+\n+\n+def test_invalid_subsystem_returns_on(hermes_home):\n+    from tools import write_approval as wa\n+    assert wa.get_write_mode(\"bogus\") == \"on\"\n+\n+\n+def test_normalize_mode_handles_yaml_bool():\n+    from tools import write_approval as wa\n+    assert wa._normalize_mode(False) == \"off\"\n+    assert wa._normalize_mode(True) == \"on\"\n+    assert wa._normalize_mode(\"approve\") == \"approve\"\n+    assert wa._normalize_mode(\"garbage\") == \"on\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Memory gate\n+# ---------------------------------------------------------------------------\n+\n+def test_memory_off_blocks_write(hermes_home):\n+    from tools.memory_tool import memory_tool, MemoryStore\n+    _set_mode(\"memory\", \"off\")\n+    store = MemoryStore(); store.load_from_disk()\n+    r = json.loads(memory_tool(\"add\", \"user\", \"should not save\", store=store))\n+    assert r[\"success\"] is False\n+    assert \"disabled\" in r[\"error\"].lower()\n+    assert store.user_entries == []\n+\n+\n+def test_memory_on_allows_write(hermes_home):\n+    from tools.memory_tool import memory_tool, MemoryStore\n+    _set_mode(\"memory\", \"on\")\n+    store = MemoryStore(); store.load_from_disk()\n+    r = json.loads(memory_tool(\"add\", \"user\", \"save me\", store=store))\n+    assert r[\"success\"] is True\n+    assert r[\"entry_count\"] == 1\n+\n+\n+def test_memory_approve_no_interactive_stages(hermes_home):\n+    # No approval callback registered and not a gateway context → stage.\n+    from tools.memory_tool import memory_tool, MemoryStore\n+    from tools import write_approval as wa\n+    _set_mode(\"memory\", \"approve\")\n+    store = MemoryStore(); store.load_from_disk()\n+    r = json.loads(memory_tool(\"add\", \"memory\", \"stage me\", store=store))\n+    assert r.get(\"staged\") is True\n+    assert r.get(\"pending_id\")\n+    # Not written to the live store yet.\n+    assert store.memory_entries == []\n+    pend = wa.list_pending(\"memory\")\n+    assert len(pend) == 1\n+    assert pend[0][\"id\"] == r[\"pending_id\"]\n+\n+\n+def test_memory_approve_then_apply(hermes_home):\n+    from tools.memory_tool import memory_tool, MemoryStore, apply_memory_pending\n+    from tools import write_approval as wa\n+    _set_mode(\"memory\", \"approve\")\n+    store = MemoryStore(); store.load_from_disk()\n+    r = json.loads(memory_tool(\"add\", \"user\", \"approved entry\", store=store))\n+    pid = r[\"pending_id\"]\n+    rec = wa.get_pending(\"memory\", pid)\n+    result = apply_memory_pending(rec[\"payload\"], store)\n+    assert result[\"success\"] is True\n+    assert \"approved entry\" in store.user_entries[0]\n+\n+\n+# ---------------------------------------------------------------------------\n+# Skill gate\n+# ---------------------------------------------------------------------------\n+\n+_SKILL = (\n+    \"---\\nname: test-skill\\ndescription: A test skill\\nversion: 1.0.0\\n---\\n\"\n+    \"# Test\\nbody\\n\"\n+)\n+\n+\n+def test_skill_off_blocks_create(hermes_home):\n+    from tools.skill_manager_tool import skill_manage\n+    _set_mode(\"skills\", \"off\")\n+    r = json.loads(skill_manage(\"create\", \"blocked-skill\", content=_SKILL))\n+    assert r[\"success\"] is False\n+    assert \"disabled\" in r[\"error\"].lower()\n+\n+\n+def test_skill_approve_always_stages(hermes_home):\n+    # Skills stage even in the foreground (too big to review inline).\n+    from tools.skill_manager_tool import skill_manage\n+    from tools import write_approval as wa\n+    _set_mode(\"skills\", \"approve\")\n+    r = json.loads(skill_manage(\"create\", \"staged-skill\", content=_SKILL))\n+    assert r.get(\"staged\") is True\n+    assert \"staged-skill\" in r.get(\"gist\", \"\")\n+    assert wa.pending_count(\"skills\") == 1\n+\n+\n+def test_skill_approve_then_apply_writes_file(hermes_home):\n+    # SKILLS_DIR is resolved at import time, so reload the skill module under\n+    # this test's HERMES_HOME to exercise the real on-disk write path.\n+    import importlib\n+    import tools.skill_manager_tool as smt\n+    importlib.reload(smt)\n+    from tools import write_approval as wa\n+    _set_mode(\"skills\", \"approve\")\n+    r = json.loads(smt.skill_manage(\"create\", \"applied-skill\", content=_SKILL))\n+    rec = wa.get_pending(\"skills\", r[\"pending_id\"])\n+    res = json.loads(smt.apply_skill_pending(rec[\"payload\"]))\n+    assert res[\"success\"] is True\n+    assert smt._find_skill(\"applied-skill\") is not None\n+\n+\n+def test_skill_create_diff_is_full_content(hermes_home):\n+    from tools.skill_manager_tool import skill_manage\n+    from tools import write_approval as wa\n+    _set_mode(\"skills\", \"approve\")\n+    r = json.loads(skill_manage(\"create\", \"diff-skill\", content=_SKILL))\n+    rec = wa.get_pending(\"skills\", r[\"pending_id\"])\n+    diff = wa.skill_pending_diff(rec)\n+    assert \"name: test-skill\" in diff\n+\n+\n+# ---------------------------------------------------------------------------\n+# Pending store CRUD\n+# ---------------------------------------------------------------------------\n+\n+def test_pending_store_roundtrip(hermes_home):\n+    from tools import write_approval as wa\n+    rec = wa.stage_write(\"memory\", {\"action\": \"add\", \"target\": \"user\", \"content\": \"x\"},\n+                         summary=\"add x\", origin=\"foreground\")\n+    assert wa.pending_count(\"memory\") == 1\n+    got = wa.get_pending(\"memory\", rec[\"id\"])\n+    assert got[\"payload\"][\"content\"] == \"x\"\n+    assert wa.discard_pending(\"memory\", rec[\"id\"]) is True\n+    assert wa.pending_count(\"memory\") == 0\n+    assert wa.get_pending(\"memory\", rec[\"id\"]) is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# Shared command handler\n+# ---------------------------------------------------------------------------\n+\n+def test_handle_pending_list_empty(hermes_home):\n+    from hermes_cli.write_approval_commands import handle_pending_subcommand\n+    from tools import write_approval as wa\n+    out = handle_pending_subcommand(wa.MEMORY, [\"pending\"])\n+    assert \"No pending memory\" in out\n+\n+\n+def test_handle_approve_all(hermes_home):\n+    from hermes_cli.write_approval_commands import handle_pending_subcommand\n+    from tools.memory_tool import MemoryStore\n+    from tools import write_approval as wa\n+    store = MemoryStore(); store.load_from_disk()\n+    wa.stage_write(\"memory\", {\"action\": \"add\", \"target\": \"user\", \"content\": \"a\"},\n+                   summary=\"a\", origin=\"foreground\")\n+    wa.stage_write(\"memory\", {\"action\": \"add\", \"target\": \"user\", \"content\": \"b\"},\n+                   summary=\"b\", origin=\"foreground\")\n+    out = handle_pending_subcommand(wa.MEMORY, [\"approve\", \"all\"], memory_store=store)\n+    assert \"Approved 2\" in out\n+    assert wa.pending_count(\"memory\") == 0\n+    assert len(store.user_entries) == 2\n+\n+\n+def test_handle_reject(hermes_home):\n+    from hermes_cli.write_approval_commands import handle_pending_subcommand\n+    from tools import write_approval as wa\n+    rec = wa.stage_write(\"skills\", {\"action\": \"create\", \"name\": \"s\"},\n+                         summary=\"create s\", origin=\"background_review\")\n+    out = handle_pending_subcommand(wa.SKILLS, [\"reject\", rec[\"id\"]])\n+    assert \"Rejected\" in out\n+    assert wa.pending_count(\"skills\") == 0\n+\n+\n+def test_handle_mode_set(hermes_home):\n+    from hermes_cli.write_approval_commands import handle_pending_subcommand\n+    from tools import write_approval as wa\n+    captured = {}\n+    out = handle_pending_subcommand(\n+        wa.MEMORY, [\"mode\", \"approve\"],\n+        set_mode_fn=lambda m: captured.update(mode=m),\n+    )\n+    assert captured[\"mode\"] == \"approve\"\n+    assert \"approve\" in out\n+\n+\n+def test_handle_mode_invalid(hermes_home):\n+    from hermes_cli.write_approval_commands import handle_pending_subcommand\n+    from tools import write_approval as wa\n+    out = handle_pending_subcommand(wa.MEMORY, [\"mode\", \"bogus\"],\n+                                    set_mode_fn=lambda m: None)\n+    assert \"Invalid mode\" in out\n+\n+\n+def test_handle_unknown_subcommand_returns_none(hermes_home):\n+    from hermes_cli.write_approval_commands import handle_pending_subcommand\n+    from tools import write_approval as wa\n+    # An unrecognized /skills subcommand (e.g. 'search') must return None so\n+    # the CLI falls through to the skills hub.\n+    out = handle_pending_subcommand(wa.SKILLS, [\"search\", \"foo\"])\n+    assert out is None"]},{"file":"tools/memory_tool.py","hunks":["@@ -606,6 +606,63 @@ def _write_file(path: Path, entries: List[str]):\n             raise RuntimeError(f\"Failed to write memory file {path}: {e}\")\n \n \n+def _apply_write_gate(action: str, target: str, content: Optional[str],\n+                      old_text: Optional[str]) -> Optional[str]:\n+    \"\"\"Evaluate the memory write gate. Returns a JSON tool-result string when\n+    the write should NOT proceed normally (blocked or staged), or None when the\n+    caller should perform the real write.\n+\n+    Only the mutating actions (add/replace/remove) are gated.\n+    \"\"\"\n+    if action not in {\"add\", \"replace\", \"remove\"}:\n+        return None\n+\n+    try:\n+        from tools import write_approval as wa\n+    except Exception:\n+        # If the gate module can't load, fail open (current behaviour) rather\n+        # than blocking all memory writes.\n+        return None\n+\n+    # Build a small inline summary/detail for the foreground approval prompt.\n+    label = \"user profile\" if target == \"user\" else \"memory\"\n+    if action == \"add\":\n+        summary = f\"add to {label}\"\n+        detail = content or \"\"\n+    elif action == \"replace\":\n+        summary = f\"replace in {label}\"\n+        detail = f\"old: {old_text}\\nnew: {content}\"\n+    else:  # remove\n+        summary = f\"remove from {label}\"\n+        detail = old_text or \"\"\n+\n+    decision = wa.evaluate_gate(wa.MEMORY, inline_summary=summary, inline_detail=detail)\n+\n+    if decision.allow:\n+        return None\n+\n+    if decision.blocked:\n+        return tool_error(decision.message, success=False)\n+\n+    # stage\n+    payload = {\n+        \"action\": action,\n+        \"target\": target,\n+        \"content\": content,\n+        \"old_text\": old_text,\n+    }\n+    record = wa.stage_write(\n+        wa.MEMORY, payload,\n+        summary=f\"{summary}: {detail[:120]}\",\n+        origin=wa.current_origin(),\n+    )\n+    return json.dumps(\n+        {\"success\": True, \"staged\": True, \"pending_id\": record[\"id\"],\n+         \"message\": decision.message},\n+        ensure_ascii=False,\n+    )\n+\n+\n def memory_tool(\n     action: str,\n     target: str = \"memory\",","@@ -624,6 +681,12 @@ def memory_tool(\n     if target not in {\"memory\", \"user\"}:\n         return tool_error(f\"Invalid target '{target}'. Use 'memory' or 'user'.\", success=False)\n \n+    # Write gate: off blocks the write; approve stages it (background) or\n+    # prompts inline (foreground). on (default) passes straight through.\n+    gate_result = _apply_write_gate(action, target, content, old_text)\n+    if gate_result is not None:\n+        return gate_result\n+\n     if action == \"add\":\n         if not content:\n             return tool_error(\"Content is required for 'add' action.\", success=False)","@@ -652,7 +715,23 @@ def check_memory_requirements() -> bool:\n     return True\n \n \n-# =============================================================================\n+def apply_memory_pending(payload: Dict[str, Any], store: \"MemoryStore\") -> Dict[str, Any]:\n+    \"\"\"Replay a staged memory write directly against the store, bypassing the\n+    write gate. Called by the /memory approve handler.\n+\n+    Returns the store's result dict.\n+    \"\"\"\n+    action = payload.get(\"action\")\n+    target = payload.get(\"target\", \"memory\")\n+    content = payload.get(\"content\") or \"\"\n+    old_text = payload.get(\"old_text\") or \"\"\n+    if action == \"add\":\n+        return store.add(target, content)\n+    if action == \"replace\":\n+        return store.replace(target, old_text, content)\n+    if action == \"remove\":\n+        return store.remove(target, old_text)\n+    return {\"success\": False, \"error\": f\"Unknown staged action '{action}'.\"}\n # OpenAI Function-Calling Schema\n # =============================================================================\n "]},{"file":"tools/skill_manager_tool.py","hunks":["@@ -822,6 +822,75 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:\n # Main entry point\n # =============================================================================\n \n+# ContextVar bypass: set while replaying an already-approved staged skill write\n+# so skill_manage() does not re-gate (and re-stage) it.\n+import contextvars as _ctxvars\n+_skill_gate_bypass: \"_ctxvars.ContextVar[bool]\" = _ctxvars.ContextVar(\n+    \"skill_gate_bypass\", default=False\n+)\n+\n+\n+def _apply_skill_write_gate(action, name, **payload_kwargs):\n+    \"\"\"Evaluate the skill write gate. Returns a JSON tool-result string when the\n+    write should NOT proceed (blocked or staged), or None to perform the real\n+    write. Bypassed during approved-pending replay.\n+    \"\"\"\n+    if action not in {\"create\", \"edit\", \"patch\", \"delete\", \"write_file\", \"remove_file\"}:\n+        return None\n+    if _skill_gate_bypass.get():\n+        return None\n+\n+    try:\n+        from tools import write_approval as wa\n+    except Exception:\n+        return None  # fail open\n+\n+    decision = wa.evaluate_gate(wa.SKILLS)\n+    if decision.allow:\n+        return None\n+    if decision.blocked:\n+        return tool_error(decision.message, success=False)\n+\n+    # stage — record the full skill_manage kwargs so approval can replay it.\n+    payload = {\"action\": action, \"name\": name}\n+    payload.update({k: v for k, v in payload_kwargs.items() if v is not None})\n+    gist = wa.skill_gist(\n+        action, name,\n+        content=payload_kwargs.get(\"content\") or \"\",\n+        file_path=payload_kwargs.get(\"file_path\") or \"\",\n+        old_string=payload_kwargs.get(\"old_string\") or \"\",\n+        new_string=payload_kwargs.get(\"new_string\") or \"\",\n+    )\n+    record = wa.stage_write(wa.SKILLS, payload, summary=gist, origin=wa.current_origin())\n+    return json.dumps(\n+        {\"success\": True, \"staged\": True, \"pending_id\": record[\"id\"],\n+         \"gist\": gist, \"message\": decision.message},\n+        ensure_ascii=False,\n+    )\n+\n+\n+def apply_skill_pending(payload: Dict[str, Any]) -> str:\n+    \"\"\"Replay a staged skill write, bypassing the gate. Returns the tool result\n+    JSON string. Called by the /skills approve handler.\n+    \"\"\"\n+    token = [redacted:secret-assignment]\n+    try:\n+        return skill_manage(\n+            action=payload.get(\"action\", \"\"),\n+            name=payload.get(\"name\", \"\"),\n+            content=payload.get(\"content\"),\n+            category=payload.get(\"category\"),\n+            file_path=payload.get(\"file_path\"),\n+            file_content=payload.get(\"file_content\"),\n+            old_string=payload.get(\"old_string\"),\n+            new_string=payload.get(\"new_string\"),\n+            replace_all=payload.get(\"replace_all\", False),\n+            absorbed_into=payload.get(\"absorbed_into\"),\n+        )\n+    finally:\n+        _skill_gate_bypass.reset(token)\n+\n+\n def skill_manage(\n     action: str,\n     name: str,","@@ -839,6 +908,19 @@ def skill_manage(\n \n     Returns JSON string with results.\n     \"\"\"\n+    # Write gate: off blocks the write; approve stages it for review (skills are\n+    # too large to review inline, so they always stage regardless of origin).\n+    # on (default) passes straight through. The gate is bypassed when this call\n+    # is itself replaying an already-approved staged write (_skill_apply_pending).\n+    gate_result = _apply_skill_write_gate(\n+        action, name, content=content, category=category,\n+        file_path=file_path, file_content=file_content,\n+        old_string=old_string, new_string=new_string,\n+        replace_all=replace_all, absorbed_into=absorbed_into,\n+    )\n+    if gate_result is not None:\n+        return gate_result\n+\n     if action == \"create\":\n         if not content:\n             return tool_error(\"content is required for 'create'. Provide the full SKILL.md text (frontmatter + body).\", success=False)"]},{"file":"tools/write_approval.py","hunks":["@@ -0,0 +1,491 @@\n+#!/usr/bin/env python3\n+\"\"\"Write-approval gate + pending store for memory and skill writes.\n+\n+Background\n+----------\n+The agent writes to two persistent stores that survive across sessions:\n+\n+  * **memory** — MEMORY.md / USER.md, small (~200 char) declarative entries\n+  * **skills** — SKILL.md + supporting files, potentially huge (10-100 KB)\n+\n+Both stores are written from two origins:\n+\n+  * **foreground** — a normal agent turn (user is present / chatting)\n+  * **background_review** — the self-improvement review fork that runs after a\n+    turn and autonomously decides what to save (the source of the\n+    \"wrong assumptions\" users complained about)\n+\n+This module lets the user gate those writes per-subsystem with a tri-state\n+``write_mode``:\n+\n+  * ``on``      — write freely (current behaviour, default)\n+  * ``off``     — never write; the tool returns a clean \"disabled\" result\n+  * ``approve`` — do not commit the write; **stage** it to a pending store and\n+    surface it for the user to approve or reject out-of-band\n+\n+The size asymmetry between memory and skills is real and unavoidable: a memory\n+entry can be reviewed inline in a chat bubble; a 100 KB SKILL.md cannot. So\n+``approve`` mode stages BOTH to disk, but review affordances differ by subsystem\n+(see ``hermes_cli`` slash handlers): memory shows full content, skills show\n+metadata + a one-line gist + a ``diff`` escape hatch (CLI/dashboard/file).\n+\n+Staging is mandatory for background-origin writes under ``approve`` (a daemon\n+thread cannot block on an interactive prompt). Foreground memory writes may\n+additionally block inline via the dangerous-command approval gate; foreground\n+skill writes always stage (too big to eyeball mid-loop).\n+\n+Pending records live under ``<HERMES_HOME>/pending/{memory,skills}/<id>.json``\n+so they survive process restarts and can be reviewed from CLI, gateway, or the\n+web dashboard.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import logging\n+import os\n+import time\n+import uuid\n+from pathlib import Path\n+from typing import Any, Dict, List, Optional\n+\n+from hermes_constants import get_hermes_home\n+\n+logger = logging.getLogger(__name__)\n+\n+# Subsystem identifiers\n+MEMORY = \"memory\"\n+SKILLS = \"skills\"\n+_SUBSYSTEMS = (MEMORY, SKILLS)\n+\n+# Tri-state write modes\n+MODE_ON = \"on\"\n+MODE_OFF = \"off\"\n+MODE_APPROVE = \"approve\"\n+_VALID_MODES = (MODE_ON, MODE_OFF, MODE_APPROVE)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Config resolution\n+# ---------------------------------------------------------------------------\n+\n+def get_write_mode(subsystem: str) -> str:\n+    \"\"\"Return the configured write_mode for ``subsystem`` (memory|skills).\n+\n+    Reads ``<subsystem>.write_mode`` from config.yaml. Falls back to ``on``\n+    (current behaviour) for any unset / invalid value so existing installs are\n+    unaffected until the user opts in.\n+    \"\"\"\n+    if subsystem not in _SUBSYSTEMS:\n+        return MODE_ON\n+    try:\n+        from hermes_cli.config import load_config, cfg_get\n+        cfg = load_config()\n+        raw = cfg_get(cfg, subsystem, \"write_mode\", default=MODE_ON)\n+    except Exception:\n+        return MODE_ON\n+    return _normalize_mode(raw)\n+\n+\n+def _normalize_mode(value: Any) -> str:\n+    \"\"\"Coerce a config value to a valid mode string.\n+\n+    YAML 1.1 parses bare ``off`` / ``on`` as booleans, so handle bools the way\n+    the approval-mode normalizer does.\n+    \"\"\"\n+    if isinstance(value, bool):\n+        return MODE_OFF if value is False else MODE_ON\n+    if isinstance(value, str):\n+        v = value.strip().lower()\n+        if v in _VALID_MODES:\n+            return v\n+    return MODE_ON\n+\n+\n+# ---------------------------------------------------------------------------\n+# Pending store (file-backed)\n+# ---------------------------------------------------------------------------\n+\n+def _pending_dir(subsystem: str) -> Path:\n+    return get_hermes_home() / \"pending\" / subsystem\n+\n+\n+def stage_write(subsystem: str, payload: Dict[str, Any],\n+                *, summary: str, origin: str) -> Dict[str, Any]:\n+    \"\"\"Persist a pending write and return a short record describing it.\n+\n+    Args:\n+        subsystem: ``memory`` or ``skills``.\n+        payload: the exact kwargs needed to replay the write when approved\n+            (e.g. ``{\"action\": \"add\", \"target\": \"user\", \"content\": \"...\"}``\n+            for memory, or the full ``skill_manage`` kwargs for skills).\n+        summary: a one-line human-readable description shown in pending lists.\n+            For skills this is the LLM/heuristic gist; for memory it can be the\n+            entry text itself.\n+        origin: ``foreground`` or ``background_review`` — recorded for audit.\n+\n+    Returns a dict with ``id`` and metadata. Best-effort: on disk failure it\n+    logs and still returns a record (the write is simply lost, which is the\n+    safe failure for an approval gate — nothing is silently committed).\n+    \"\"\"\n+    pid = uuid.uuid4().hex[:8]\n+    record = {\n+        \"id\": pid,\n+        \"subsystem\": subsystem,\n+        \"action\": payload.get(\"action\", \"\"),\n+        \"summary\": (summary or \"\").strip(),\n+        \"origin\": origin or \"foreground\",\n+        \"created_at\": time.time(),\n+        \"payload\": payload,\n+    }\n+    try:\n+        d = _pending_dir(subsystem)\n+        d.mkdir(parents=True, exist_ok=True)\n+        path = d / f\"{pid}.json\"\n+        tmp = path.with_suffix(\".json.tmp\")\n+        tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding=\"utf-8\")\n+        os.replace(tmp, path)\n+    except Exception as e:  # pragma: no cover - disk failure path\n+        logger.error(\"Failed to stage pending %s write: %s\", subsystem, e, exc_info=True)\n+    return record\n+\n+\n+def list_pending(subsystem: str) -> List[Dict[str, Any]]:\n+    \"\"\"Return all pending records for ``subsystem``, oldest first.\"\"\"\n+    d = _pending_dir(subsystem)\n+    if not d.exists():\n+        return []\n+    records: List[Dict[str, Any]] = []\n+    for p in d.glob(\"*.json\"):\n+        try:\n+            records.append(json.loads(p.read_text(encoding=\"utf-8\")))\n+        except Exception:\n+            logger.warning(\"Skipping unreadable pending record: %s\", p)\n+    records.sort(key=lambda r: r.get(\"created_at\", 0))\n+    return records\n+\n+\n+def get_pending(subsystem: str, pending_id: str) -> Optional[Dict[str, Any]]:\n+    \"\"\"Return a single pending record by id, or None.\"\"\"\n+    path = _pending_dir(subsystem) / f\"{pending_id}.json\"\n+    if not path.exists():\n+        return None\n+    try:\n+        return json.loads(path.read_text(encoding=\"utf-8\"))\n+    except Exception:\n+        return None\n+\n+\n+def discard_pending(subsystem: str, pending_id: str) -> bool:\n+    \"\"\"Delete a pending record. Returns True if it existed.\"\"\"\n+    path = _pending_dir(subsystem) / f\"{pending_id}.json\"\n+    try:\n+        if path.exists():\n+            path.unlink()\n+            return True\n+    except Exception as e:  # pragma: no cover\n+        logger.error(\"Failed to discard pending %s/%s: %s\", subsystem, pending_id, e)\n+    return False\n+\n+\n+def pending_count(subsystem: str) -> int:\n+    \"\"\"Cheap count of pending records (for notification badges).\"\"\"\n+    d = _pending_dir(subsystem)\n+    if not d.exists():\n+        return 0\n+    try:\n+        return sum(1 for _ in d.glob(\"*.json\"))\n+    except Exception:\n+        return 0\n+\n+\n+# ---------------------------------------------------------------------------\n+# Write origin\n+# ---------------------------------------------------------------------------\n+\n+def current_origin() -> str:\n+    \"\"\"Return the active write origin: ``foreground`` or ``background_review``.\n+\n+    Reuses the skill-provenance ContextVar, which the background review fork\n+    already sets (see ``agent.background_review`` /\n+    ``AIAgent._spawn_background_review``). Foreground agent turns leave it at\n+    the default ``foreground``.\n+    \"\"\"\n+    try:\n+        from tools.skill_provenance import get_current_write_origin\n+        return get_current_write_origin()\n+    except Exception:\n+        return \"foreground\"\n+\n+\n+def is_background() -> bool:\n+    return current_origin() == \"background_review\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Gate decision\n+# ---------------------------------------------------------------------------\n+\n+class GateDecision:\n+    \"\"\"Result of evaluating the write gate for a single write attempt.\n+\n+    Exactly one of the boolean flags is True:\n+      * ``allow``  — proceed with the real write (mode ``on``, or an inline\n+        approval was granted).\n+      * ``blocked`` — refuse the write (mode ``off``, or an inline approval was\n+        denied). ``message`` explains why; surface it to the agent.\n+      * ``stage``  — do not write; the caller should stage the payload via\n+        ``stage_write`` (mode ``approve`` for a background write, or a\n+        foreground write with no interactive prompt available). ``message`` is\n+        the user-facing \"staged for approval\" note.\n+    \"\"\"\n+\n+    __slots__ = (\"allow\", \"blocked\", \"stage\", \"message\")\n+\n+    def __init__(self, *, allow=False, blocked=False, stage=False, message=\"\"):\n+        self.allow = allow\n+        self.blocked = blocked\n+        self.stage = stage\n+        self.message = message\n+\n+\n+def evaluate_gate(subsystem: str, *, inline_summary: str = \"\",\n+                  inline_detail: str = \"\") -> GateDecision:\n+    \"\"\"Decide what to do with a pending write for ``subsystem``.\n+\n+    Args:\n+        subsystem: ``memory`` or ``skills``.\n+        inline_summary: short description used as the inline approval prompt\n+            header (memory foreground path only).\n+        inline_detail: full content shown in the inline prompt (memory entries\n+            are small; skills never take the inline path).\n+\n+    Mode matrix:\n+        on       → allow\n+        off      → blocked\n+        approve  → memory + foreground   → inline approve/deny prompt\n+                   memory + background    → stage\n+                   skills (any origin)    → stage (too big to review inline)\n+    \"\"\"\n+    mode = get_write_mode(subsystem)\n+\n+    if mode == MODE_ON:\n+        return GateDecision(allow=True)\n+\n+    if mode == MODE_OFF:\n+        return GateDecision(\n+            blocked=True,\n+            message=(\n+                f\"{subsystem.capitalize()} writes are disabled \"\n+                f\"({subsystem}.write_mode = off). The change was not saved. \"\n+                f\"Set {subsystem}.write_mode to 'on' or 'approve' to allow writes.\"\n+            ),\n+        )\n+\n+    # mode == approve\n+    background = is_background()\n+\n+    # Skills always stage — a SKILL.md is too large to review inline, and a\n+    # background skill write happens in a daemon thread with no user present.\n+    if subsystem == SKILLS or background:\n+        where = \"/skills pending\" if subsystem == SKILLS else \"/memory pending\"\n+        return GateDecision(\n+            stage=True,\n+            message=(\n+                f\"Staged for approval ({subsystem}.write_mode = approve). \"\n+                f\"Not yet saved — review with {where}.\"\n+            ),\n+        )\n+\n+    # Memory + foreground: if an interactive approval channel exists (CLI\n+    # prompt_toolkit callback, or a gateway approve/deny round-trip), prompt\n+    # inline — entries are small enough to show in full. Otherwise (script,\n+    # batch, no listener) stage instead of forcing a blind deny.\n+    if _interactive_approval_available():\n+        granted = _prompt_inline_memory_approval(inline_summary, inline_detail)\n+        if granted is True:\n+            return GateDecision(allow=True)\n+        if granted is False:\n+            return GateDecision(\n+                blocked=True,\n+                message=\"Memory write denied by user. The change was not saved.\",\n+            )\n+        # granted is None → prompt failed; fall through to staging.\n+\n+    return GateDecision(\n+        stage=True,\n+        message=(\n+            \"Staged for approval (memory.write_mode = approve). \"\n+            \"Not yet saved — review with /memory pending.\"\n+        ),\n+    )\n+\n+\n+def _interactive_approval_available() -> bool:\n+    \"\"\"True when a foreground memory write can be approved inline.\n+\n+    Either a per-thread approval callback is registered (interactive CLI), or\n+    the call is inside a gateway/API session that supports the /approve //deny\n+    round-trip. Scripts, cron, and background threads have neither → stage.\n+    \"\"\"\n+    try:\n+        from tools.terminal_tool import _get_approval_callback\n+        if _get_approval_callback() is not None:\n+            return True\n+    except Exception:\n+        pass\n+    try:\n+        from tools.approval import _is_gateway_approval_context\n+        return bool(_is_gateway_approval_context())\n+    except Exception:\n+        return False\n+\n+\n+def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]:\n+    \"\"\"Prompt the user inline to approve a memory write.\n+\n+    Returns True (approved), False (denied), or None (no interactive prompt\n+    available on this thread → caller should stage instead).\n+\n+    Reuses the dangerous-command approval machinery so the CLI prompt_toolkit\n+    callback and the gateway ``/approve`` ``/deny`` round-trip both work without\n+    duplicating that plumbing.\n+    \"\"\"\n+    try:\n+        from tools.approval import prompt_dangerous_approval\n+    except Exception:\n+        return None\n+\n+    header = summary.strip() or \"Save to memory?\"\n+    body = detail.strip()\n+    description = f\"Save to memory: {header}\"\n+    command = body if body else header\n+    try:\n+        choice = prompt_dangerous_approval(\n+            command,\n+            description,\n+            allow_permanent=False,\n+        )\n+    except Exception as e:  # pragma: no cover\n+        logger.error(\"Inline memory approval prompt failed: %s\", e)\n+        return None\n+\n+    if choice in {\"once\", \"session\"}:\n+        return True\n+    if choice == \"deny\":\n+        return False\n+    # Any other outcome (e.g. timeout that returns \"deny\" already handled) →\n+    # treat unknown as no-decision so we stage rather than silently drop.\n+    return None\n+\n+\n+# ---------------------------------------------------------------------------\n+# Skill-specific helpers (gist + diff for the review affordances)\n+# ---------------------------------------------------------------------------\n+\n+def skill_gist(action: str, name: str, *, content: str = \"\",\n+               file_path: str = \"\", old_string: str = \"\",\n+               new_string: str = \"\") -> str:\n+    \"\"\"Build a one-line human gist for a pending skill write.\n+\n+    Heuristic, no model call — the gist surfaces enough to decide approve/reject\n+    in a chat bubble, while the full diff stays behind /skills diff (CLI/\n+    dashboard/file). For create/edit it pulls the frontmatter ``description:``;\n+    for patch/write_file it describes the size of the change.\n+    \"\"\"\n+    if action in {\"create\", \"edit\"} and content:\n+        desc = _frontmatter_description(content)\n+        size = f\"{len(content) // 1024 + 1} KB\" if len(content) >= 1024 else f\"{len(content)} chars\"\n+        verb = \"create\" if action == \"create\" else \"rewrite\"\n+        if desc:\n+            return f\"{verb} '{name}' — {desc} ({size})\"\n+        return f\"{verb} '{name}' ({size})\"\n+    if action == \"patch\":\n+        target = file_path or \"SKILL.md\"\n+        removed = old_string.count(\"\\n\") + 1 if old_string else 0\n+        added = new_string.count(\"\\n\") + 1 if new_string else 0\n+        return f\"patch '{name}' {target} (+{added}/-{removed} lines)\"\n+    if action == \"write_file\":\n+        return f\"write {file_path} in '{name}'\"\n+    if action == \"remove_file\":\n+        return f\"remove {file_path} from '{name}'\"\n+    if action == \"delete\":\n+        return f\"delete skill '{name}'\"\n+    return f\"{action} '{name}'\"\n+\n+\n+def _frontmatter_description(content: str) -> str:\n+    \"\"\"Extract the ``description:`` value from SKILL.md YAML frontmatter.\"\"\"\n+    import re\n+    m = re.search(r\"^description:\\s*(.+)$\", content, re.MULTILINE)\n+    if not m:\n+        return \"\"\n+    desc = m.group(1).strip().strip(\"'\\\"\")\n+    return desc[:140]\n+\n+\n+def skill_pending_diff(record: Dict[str, Any]) -> str:\n+    \"\"\"Build a full unified diff (or full content) for a staged skill write.\n+\n+    Used by /skills diff <id> on a surface that can render it (CLI pager, web\n+    dashboard, or by opening the pending JSON file). For create this is the new\n+    file content; for edit/patch it is a unified diff against the current\n+    on-disk skill.\n+    \"\"\"\n+    import difflib\n+    payload = record.get(\"payload\", {})\n+    action = payload.get(\"action\", \"\")\n+    name = payload.get(\"name\", \"\")\n+\n+    if action == \"create\":\n+        return (payload.get(\"content\") or \"\")\n+\n+    # Resolve current on-disk content for diffable actions.\n+    try:\n+        from tools.skill_manager_tool import _find_skill\n+    except Exception:\n+        _find_skill = None  # type: ignore\n+\n+    current = \"\"\n+    target_label = \"SKILL.md\"\n+    if _find_skill is not None:\n+        found = _find_skill(name)\n+        if found:\n+            base = found[\"path\"]\n+            if action == \"edit\":\n+                p = base / \"SKILL.md\"\n+            elif action in {\"patch\", \"write_file\"}:\n+                rel = payload.get(\"file_path\") or \"SKILL.md\"\n+                p = base / rel\n+                target_label = rel\n+            else:\n+                p = base / \"SKILL.md\"\n+            try:\n+                if p.exists():\n+                    current = p.read_text(encoding=\"utf-8\")\n+            except Exception:\n+                current = \"\"\n+\n+    if action == \"edit\":\n+        new = payload.get(\"content\") or \"\"\n+    elif action == \"patch\":\n+        old_s = payload.get(\"old_string\") or \"\"\n+        new_s = payload.get(\"new_string\") or \"\"\n+        new = current.replace(old_s, new_s) if current else f\"(patch {old_s!r} → {new_s!r})\"\n+    elif action == \"write_file\":\n+        new = payload.get(\"file_content\") or \"\"\n+    elif action == \"remove_file\":\n+        return f\"remove file: {payload.get('file_path')} from skill '{name}'\"\n+    elif action == \"delete\":\n+        return f\"delete skill '{name}'\"\n+    else:\n+        return f\"({action} on '{name}')\"\n+\n+    diff = difflib.unified_diff(\n+        current.splitlines(keepends=True),\n+        new.splitlines(keepends=True),\n+        fromfile=f\"a/{target_label}\",\n+        tofile=f\"b/{target_label}\",\n+    )\n+    text = \"\".join(diff)\n+    return text or \"(no textual change)\""]},{"file":"website/docs/user-guide/features/memory.md","hunks":["@@ -209,8 +209,62 @@ memory:\n   user_profile_enabled: true\n   memory_char_limit: 2200   # ~800 tokens\n   user_char_limit: 1375     # ~500 tokens\n+  write_mode: on            # on | off | approve\n ```\n \n+## Controlling memory writes (`write_mode`)\n+\n+By default the agent saves memory freely — including from the background\n+self-improvement review that runs after a turn. If you'd rather not have\n+memory written behind your back, `memory.write_mode` gives you three options\n+(applied to **both** foreground turns and the background review):\n+\n+| Mode | Behaviour |\n+|------|-----------|\n+| `on` | Write freely (default — current behaviour). |\n+| `off` | Never write. The memory tool returns a clean \"disabled\" result; nothing is saved. |\n+| `approve` | Don't commit writes — review them first. Foreground writes prompt you inline (entries are small enough to read in a chat bubble). Background-review writes are **staged** instead of committed (a background thread can't block on a prompt). |\n+\n+Review staged writes from the CLI or any messaging platform:\n+\n+```\n+/memory pending             # list staged memory writes (auto ones tagged [auto])\n+/memory approve <id>        # apply one (or 'all')\n+/memory reject <id>         # drop one (or 'all')\n+/memory mode approve        # change write_mode and persist it\n+```\n+\n+This is the answer to \"the agent saved a wrong assumption about me\": set\n+`write_mode: approve`, and every save — especially the unprompted background\n+ones — waits for your yes/no before it ever enters your profile.\n+\n+## Controlling skill writes (`skills.write_mode`)\n+\n+Skills use the same three-state gate, but the review UX differs because a\n+`SKILL.md` is far too large to read in a chat bubble:\n+\n+```yaml\n+skills:\n+  write_mode: on            # on | off | approve\n+```\n+\n+In `approve` mode, skill writes (create / edit / patch / write_file / delete)\n+always **stage** regardless of origin. You review the one-line gist inline, but\n+the full diff stays out-of-band:\n+\n+```\n+/skills pending             # list staged skill writes + a one-line gist each\n+/skills diff <id>           # full unified diff (best viewed in CLI or dashboard)\n+/skills approve <id>        # apply it (or 'all')\n+/skills reject <id>         # drop it (or 'all')\n+/skills mode approve        # change write_mode and persist it\n+```\n+\n+On a messaging platform, approve a skill from its gist + metadata, or open\n+`/skills diff` on the CLI / dashboard / the staged file under\n+`~/.hermes/pending/skills/<id>.json` when you want to read the whole change.\n+\n+\n ## External Memory Providers\n \n For deeper, persistent memory that goes beyond MEMORY.md and USER.md, Hermes ships with 8 external memory provider plugins — including Honcho, OpenViking, Mem0, Hindsight, Holographic, RetainDB, ByteRover, and Supermemory.\n"]}]},{"kind":"pr","number":41485,"title":"feat(#41444): skill drafter with provenance metadata and draft suppression","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"96af61b6ef93087e1ef9c3ee20b309b7c0fcfdad","number":0},{"kind":"pr","value":"#38199","number":38199}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}