{"repo":"NousResearch/hermes-agent","public_id":"4244","display_label":"Guards for non-dict skill frontmatter metadata field","summary":"Multiple bug reports and fixes addressing crashes in skill/journey code when the frontmatter metadata field is null, a string, or otherwise not a dict. Changes add defensive type checks in extract_skill_conditions and related parsers.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56641},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-01T21:34:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.28952380952380957},"fix_edges":[{"issue_number":56533,"pr_number":56641,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":4244,"title":"[Bug]: Skill frontmatter parser crashes when `metadata` is a YAML string instead of a mapping","url":null,"state":"closed"},{"kind":"issue","number":57986,"title":"[Bug]: /journey crashes when a skill’s frontmatter metadata is not a dict","url":null,"state":"closed"},{"kind":"issue","number":59946,"title":"Crash: \"AttributeError: NoneType object has no attribute get\" in /journey when skill frontmatter has metadata: null","url":null,"state":"closed"},{"kind":"pr","number":4247,"title":"fix(skills): handle non-dict metadata in extract_skill_conditions","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":4794,"title":"fix(skill): parse stringified metadata mappings defensively","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":56641,"title":"fix(journey): crash on non-dict skill metadata + ANSI leaks in CLI/desktop","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/learning_graph.py","cli.py","hermes_cli/cli_commands_mixin.py","hermes_cli/journey.py","tests/agent/test_learning_graph.py","tests/hermes_cli/test_journey_render.py","tests/tui_gateway/test_slash_worker_ansi.py","tui_gateway/slash_worker.py"],"fix_hunks":[{"file":"agent/learning_graph.py","hunks":["@@ -48,8 +48,16 @@ def _frontmatter(text: str) -> dict[str, Any]:\n         return {}\n \n \n+def _hermes_meta(fm: dict[str, Any]) -> dict[str, Any]:\n+    \"\"\"``metadata.hermes`` as a dict, tolerant of the string-valued frontmatter\n+    that ``parse_frontmatter``'s malformed-YAML fallback produces.\"\"\"\n+    meta = fm.get(\"metadata\")\n+    hermes = meta.get(\"hermes\") if isinstance(meta, dict) else None\n+    return hermes if isinstance(hermes, dict) else {}\n+\n+\n def _related(fm: dict[str, Any]) -> list[str]:\n-    raw = fm.get(\"related_skills\") or (fm.get(\"metadata\", {}).get(\"hermes\", {}) or {}).get(\"related_skills\")\n+    raw = fm.get(\"related_skills\") or _hermes_meta(fm).get(\"related_skills\")\n     if isinstance(raw, list):\n         return [str(r).strip() for r in raw if str(r).strip()]\n     if isinstance(raw, str):","@@ -58,7 +66,7 @@ def _related(fm: dict[str, Any]) -> list[str]:\n \n \n def _category(fm: dict[str, Any], skill_md: Path) -> str:\n-    cat = fm.get(\"category\") or (fm.get(\"metadata\", {}).get(\"hermes\", {}) or {}).get(\"category\")\n+    cat = fm.get(\"category\") or _hermes_meta(fm).get(\"category\")\n     if cat:\n         return str(cat)\n     # …/skills/<category>/<skill>/SKILL.md"]},{"file":"cli.py","hunks":["@@ -8640,21 +8640,7 @@ def process_command(self, command: str) -> bool:\n         elif canonical == \"agents\":\n             self._handle_agents_command()\n         elif canonical == \"journey\":\n-            try:\n-                import argparse\n-                import shlex\n-\n-                from hermes_cli.journey import register_cli as _register_journey_cli\n-\n-                parser = argparse.ArgumentParser(prog=\"/journey\", add_help=False)\n-                _register_journey_cli(parser)\n-                argv = shlex.split(cmd_original.split(None, 1)[1]) if len(cmd_original.split(None, 1)) > 1 else []\n-                args = parser.parse_args(argv)\n-                args.func(args)\n-            except SystemExit:\n-                pass\n-            except Exception as exc:\n-                _cprint(f\"  /journey failed: {exc}\")\n+            self._handle_journey_command(cmd_original)\n         elif canonical == \"background\":\n             self._handle_background_command(cmd_original)\n         elif canonical == \"queue\":"]},{"file":"hermes_cli/cli_commands_mixin.py","hunks":["@@ -294,6 +294,43 @@ def _handle_agents_command(self):\n         agent_running = getattr(self, \"_agent_running\", False)\n         _cprint(f\"  Agent: {'running' if agent_running else 'idle'}\")\n \n+    def _handle_journey_command(self, cmd_original: str) -> None:\n+        \"\"\"Handle /journey — the learning timeline (see `hermes journey`).\n+\n+        The read-only views (default + ``list``) render Rich color, which\n+        patch_stdout would swallow as raw escapes; capture with forced ANSI and\n+        re-emit through ``_cprint``. ``delete``/``edit`` are interactive\n+        (confirm prompt / ``$EDITOR``) so they keep the real stdio.\n+        \"\"\"\n+        import argparse\n+        import io\n+        import shlex\n+        from contextlib import redirect_stdout\n+\n+        from cli import _cprint\n+        from hermes_cli.journey import register_cli\n+\n+        parser = argparse.ArgumentParser(prog=\"/journey\", add_help=False)\n+        register_cli(parser)\n+        rest = cmd_original.split(None, 1)\n+        try:\n+            args = parser.parse_args(shlex.split(rest[1]) if len(rest) > 1 else [])\n+        except SystemExit:\n+            return\n+\n+        interactive = getattr(args, \"journey_action\", None) in (\"delete\", \"edit\")\n+        try:\n+            if interactive:\n+                args.func(args)\n+                return\n+            args.force_color = True\n+            buf = io.StringIO()\n+            with redirect_stdout(buf):\n+                args.func(args)\n+            _cprint(buf.getvalue().rstrip(\"\\n\"))\n+        except Exception as exc:\n+            _cprint(f\"  /journey failed: {exc}\")\n+\n     def _handle_paste_command(self):\n         \"\"\"Handle /paste — explicitly check clipboard for an image.\n "]},{"file":"hermes_cli/journey.py","hunks":["@@ -171,6 +171,17 @@ def label_row(item) -> Text:\n     return Group(*parts)\n \n \n+def _console(*, color: bool, width: Optional[int] = None, force: bool = False):\n+    \"\"\"A Rich console. ``force`` emits truecolor ANSI even into a captured\n+    stream — the interactive CLI grabs that output and re-renders it through\n+    prompt_toolkit (raw escapes to a real terminal would otherwise be\n+    swallowed). Mirrors the ``ChatConsole`` idiom in ``cli.py``.\"\"\"\n+    from rich.console import Console\n+\n+    extra = {\"force_terminal\": True, \"color_system\": \"truecolor\"} if force else {}\n+    return Console(no_color=not color, width=width, **extra)\n+\n+\n def _cmd_show(args: argparse.Namespace) -> int:\n     from rich.console import Console\n ","@@ -183,7 +194,7 @@ def _cmd_show(args: argparse.Namespace) -> int:\n     payload = _build_payload()\n     color = not bool(getattr(args, \"no_color\", False))\n     cols, rows = _term_size(getattr(args, \"width\", None), getattr(args, \"height\", None))\n-    console = Console(no_color=not color, width=cols)\n+    console = _console(color=color, width=cols, force=bool(getattr(args, \"force_color\", False)))\n \n     if not payload.get(\"nodes\"):\n         console.print(","@@ -226,11 +237,9 @@ def _clamp(v: float, lo: float, hi: float) -> float:\n \n \n def _cmd_list(args: argparse.Namespace) -> int:\n-    from rich.console import Console\n-\n     from agent.learning_graph_render import format_date\n \n-    console = Console(no_color=bool(getattr(args, \"no_color\", False)))\n+    console = _console(color=not bool(getattr(args, \"no_color\", False)), force=bool(getattr(args, \"force_color\", False)))\n     nodes = sorted(_build_payload().get(\"nodes\", []), key=lambda n: n.get(\"timestamp\") or 0)\n     if not nodes:\n         console.print(\"[grey62]No learning yet.[/grey62]\")","@@ -315,6 +324,8 @@ def register_cli(parent: argparse.ArgumentParser) -> None:\n     parent.add_argument(\"--width\", type=int, default=None, help=\"Override render width in columns.\")\n     parent.add_argument(\"--height\", type=int, default=None, help=\"Override render height in rows.\")\n     parent.add_argument(\"--no-color\", action=\"store_true\", help=\"Disable color output.\")\n+    # Force ANSI even when stdout is captured — the interactive CLI re-renders it.\n+    parent.add_argument(\"--force-color\", action=\"store_true\", help=argparse.SUPPRESS)\n     parent.add_argument(\"--json\", action=\"store_true\", help=\"Print the raw graph payload as JSON and exit.\")\n     parent.set_defaults(func=_cmd_show)\n ","@@ -322,6 +333,7 @@ def register_cli(parent: argparse.ArgumentParser) -> None:\n \n     p_list = sub.add_parser(\"list\", help=\"List node ids (for delete/edit).\")\n     p_list.add_argument(\"--no-color\", action=\"store_true\")\n+    p_list.add_argument(\"--force-color\", action=\"store_true\", help=argparse.SUPPRESS)\n     p_list.set_defaults(func=_cmd_list)\n \n     p_del = sub.add_parser(\"delete\", help=\"Delete a learned skill (archived) or memory by node id.\")"]},{"file":"tests/agent/test_learning_graph.py","hunks":["@@ -88,6 +88,30 @@ def test_memory_is_cards_split_on_separator(tmp_path):\n     assert any(n[\"kind\"] == \"memory\" for n in graph[\"nodes\"])\n \n \n+def test_malformed_frontmatter_metadata_does_not_crash(tmp_path):\n+    \"\"\"``parse_frontmatter``'s malformed-YAML fallback stores every value as a\n+    string, so ``metadata`` can be a str. The graph must tolerate that instead\n+    of crashing on chained ``.get()`` (the /journey base-CLI crash).\"\"\"\n+    skill_dir = tmp_path / \"skills\" / \"misc\" / \"bad-skill\"\n+    skill_dir.mkdir(parents=True)\n+    # The unterminated quote makes yaml_load raise → fallback → metadata is a str.\n+    skill_dir.joinpath(\"SKILL.md\").write_text(\n+        '---\\nname: bad-skill\\nmetadata: not-a-dict\\ndescription: \"oops\\n---\\n# Bad\\n',\n+        encoding=\"utf-8\",\n+    )\n+\n+    node = learning_graph.build_skill_nodes([(\"profile\", tmp_path / \"skills\")])[\"bad-skill\"]\n+\n+    assert node.category == \"misc\"  # directory fallback, not a crash\n+    assert node.related == []\n+\n+\n+def test_hermes_meta_tolerates_non_dict():\n+    assert learning_graph._hermes_meta({\"metadata\": \"junk\"}) == {}\n+    assert learning_graph._hermes_meta({\"metadata\": {\"hermes\": \"junk\"}}) == {}\n+    assert learning_graph._hermes_meta({\"metadata\": {\"hermes\": {\"category\": \"x\"}}}) == {\"category\": \"x\"}\n+\n+\n def test_full_payload_shape_and_edge_integrity(tmp_path):\n     home = tmp_path / \".hermes\"\n     home.mkdir()"]},{"file":"tests/hermes_cli/test_journey_render.py","hunks":["@@ -0,0 +1,38 @@\n+\"\"\"Behavior contracts for /journey output routing.\n+\n+The interactive CLI captures Rich output and re-renders it through\n+prompt_toolkit, so it needs forced ANSI (``--force-color``); chat surfaces\n+render plain text, so the default captured path must stay escape-free.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import argparse\n+import contextlib\n+import io\n+\n+\n+def _capture(argv: list[str], *, force: bool) -> str:\n+    from hermes_cli.journey import register_cli\n+\n+    parser = argparse.ArgumentParser(add_help=False)\n+    register_cli(parser)\n+    args = parser.parse_args(argv)\n+    if force:\n+        args.force_color = True\n+\n+    buf = io.StringIO()\n+    with contextlib.redirect_stdout(buf):\n+        args.func(args)\n+    return buf.getvalue()\n+\n+\n+def test_force_color_emits_ansi_for_reemission():\n+    assert \"\\x1b[\" in _capture([], force=True)\n+    assert \"\\x1b[\" in _capture([\"list\"], force=True)\n+\n+\n+def test_default_capture_is_plain_for_chat_bubbles():\n+    # Rich auto-detects the StringIO as non-tty → no color, no raw escapes.\n+    assert \"\\x1b[\" not in _capture([], force=False)\n+    assert \"\\x1b[\" not in _capture([\"list\"], force=False)"]},{"file":"tests/tui_gateway/test_slash_worker_ansi.py","hunks":["@@ -0,0 +1,23 @@\n+\"\"\"The slash worker feeds desktop chat bubbles, which render plain text — so\n+any ANSI a worker-routed command emits (e.g. /journey's own Rich Console) must\n+be stripped from the worker's return value.\"\"\"\n+\n+from __future__ import annotations\n+\n+\n+class _FakeCLI:\n+    console = None\n+\n+    def process_command(self, cmd: str) -> None:\n+        import sys\n+\n+        sys.stdout.write(\"\\x1b[38;2;1;2;3mcolored\\x1b[0m plain\")\n+\n+\n+def test_run_strips_ansi_from_output():\n+    from tui_gateway import slash_worker\n+\n+    out = slash_worker._run(_FakeCLI(), \"/anything\")\n+\n+    assert \"\\x1b[\" not in out\n+    assert out == \"colored plain\""]},{"file":"tui_gateway/slash_worker.py","hunks":["@@ -102,7 +102,14 @@ def _run(cli: HermesCLI, command: str) -> str:\n         if old is not None:\n             cli_mod._cprint = old\n \n-    return buf.getvalue().rstrip()\n+    # Desktop chat bubbles render plain text, not ANSI. A worker-routed command\n+    # that emits Rich color (e.g. /journey building its own Console, which picks\n+    # up truecolor from the gateway's inherited COLORTERM) would otherwise leak\n+    # raw escapes; strip them at the single choke point. (The TUI opens /journey\n+    # as an overlay, so it never travels this path.)\n+    from tools.ansi_strip import strip_ansi\n+\n+    return strip_ansi(buf.getvalue().rstrip())\n \n \n def main():\n"]}]},{"kind":"pr","number":58139,"title":"fix: _category now handles non-dict frontmatter gracefully","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}