From 085fc5d001adfd33b0f2a0813fddaeb876a98e00 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 17 Jun 2026 17:41:23 +0700 Subject: [PATCH 1/3] feat(skills): find & diff user-modified bundled skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes update` keeps (won't overwrite) bundled skills the user edited locally, but only printed a count — "~ N user-modified (kept)" — with no way to learn which skills, or see what changed. Reverting already existed (`hermes skills reset [--restore]`); discovery and inspection did not. Add two CLI commands (zero model-tool footprint), reusing the manifest origin-hash that sync already maintains: - `hermes skills list-modified [--json]` — list the bundled skills whose on-disk copy diverges from the last-synced origin hash (the exact test the sync loop uses to decide what to skip). - `hermes skills diff ` — unified diff between the user's copy and the current bundled (stock) version, so the user can confirm what changed before reverting. Both are mirrored as `/skills list-modified` and `/skills diff`. The `hermes update` notice now points at `hermes skills list-modified`. Core helpers `list_user_modified_bundled_skills()` and `diff_bundled_skill()` live in tools/skills_sync.py alongside the existing reset logic. --- hermes_cli/main.py | 4 + hermes_cli/skills_hub.py | 84 ++++++++++++++++- hermes_cli/subcommands/skills.py | 29 ++++++ tools/skills_sync.py | 156 ++++++++++++++++++++++++++++++- 4 files changed, 271 insertions(+), 2 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0394ef90a2e..376de5bd543 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9061,6 +9061,10 @@ def _cmd_update_impl(args, gateway_mode: bool): ) if result.get("user_modified"): print(f" ~ {len(result['user_modified'])} user-modified (kept)") + print( + " → see them: hermes skills list-modified " + "(diff/reset to resume updates)" + ) if result.get("cleaned"): print(f" − {len(result['cleaned'])} removed from manifest") if not result["copied"] and not result.get("updated"): diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index f1e4f83b2c7..d9ab6366719 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -1149,6 +1149,73 @@ def do_reset(name: str, restore: bool = False, c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n") +def do_list_modified(console: Optional[Console] = None, + as_json: bool = False) -> None: + """List bundled skills the user has edited (which `hermes update` keeps).""" + from tools.skills_sync import list_user_modified_bundled_skills + + c = console or _console + modified = list_user_modified_bundled_skills() + + if as_json: + import json + + c.print(json.dumps([m["name"] for m in modified])) + return + + if not modified: + c.print("[dim]No user-modified bundled skills — everything tracks upstream.[/]\n") + return + + c.print(f"\n[bold]{len(modified)} user-modified bundled skill(s)[/] " + "[dim](kept as-is by `hermes update`):[/]") + for entry in modified: + c.print(f" [yellow]~[/] {entry['name']}") + c.print() + c.print("[dim]See changes: hermes skills diff [/]") + c.print("[dim]Resume updates: hermes skills reset (keep your copy, re-baseline)[/]") + c.print("[dim]Revert to stock: hermes skills reset --restore[/]\n") + + +def do_diff(name: str, console: Optional[Console] = None) -> None: + """Show how the user's copy of a bundled skill differs from the stock version.""" + from tools.skills_sync import diff_bundled_skill + + c = console or _console + result = diff_bundled_skill(name) + + if not result["ok"]: + c.print(f"[bold red]Error:[/] {result['message']}\n") + return + + if not result["modified"]: + c.print(f"[green]{result['message']}[/]\n") + return + + c.print(f"\n[bold]{result['message']}[/]\n") + for entry in result["diffs"]: + status = entry["status"] + if status == "modified": + # Render the unified diff with light coloring. + for line in entry["diff"].splitlines(): + if line.startswith("+") and not line.startswith("+++"): + c.print(f"[green]{line}[/]") + elif line.startswith("-") and not line.startswith("---"): + c.print(f"[red]{line}[/]") + elif line.startswith("@@"): + c.print(f"[cyan]{line}[/]") + else: + c.print(line, highlight=False) + elif status == "added": + c.print(f"[green]+ only in your copy:[/] {entry['path']}") + elif status == "removed": + c.print(f"[red]- only in stock:[/] {entry['path']}") + else: # binary + c.print(f"[yellow]~ {entry['path']}:[/] binary file differs") + c.print() + c.print(f"[dim]Revert with: hermes skills reset {name} --restore[/]\n") + + def do_opt_out(remove: bool = False, console: Optional[Console] = None, skip_confirm: bool = False, @@ -1624,6 +1691,10 @@ def skills_command(args) -> None: elif action == "reset": do_reset(args.name, restore=getattr(args, "restore", False), skip_confirm=getattr(args, "yes", False)) + elif action == "list-modified": + do_list_modified(as_json=getattr(args, "json", False)) + elif action == "diff": + do_diff(args.name) elif action == "opt-out": do_opt_out(remove=getattr(args, "remove", False), skip_confirm=getattr(args, "yes", False)) @@ -1654,7 +1725,7 @@ def skills_command(args) -> None: return do_tap(tap_action, repo=repo) else: - _console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n") + _console.print("Usage: hermes skills [browse|search|install|inspect|list|list-modified|diff|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n") _console.print("Run 'hermes skills --help' for details.\n") @@ -1826,6 +1897,15 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: do_reset(name, restore=restore, console=c, skip_confirm=True, invalidate_cache=invalidate_cache) + elif action in {"list-modified", "modified"}: + do_list_modified(console=c, as_json="--json" in args) + + elif action == "diff": + if not args: + c.print("[bold red]Usage:[/] /skills diff \n") + return + do_diff(args[0], console=c) + elif action == "publish": if not args: c.print("[bold red]Usage:[/] /skills publish [--to github] [--repo owner/repo]\n") @@ -1883,6 +1963,8 @@ def _print_skills_help(console: Console) -> None: " [cyan]update[/] [name] Update hub skills with upstream changes\n" " [cyan]audit[/] [name] Re-scan hub skills for security\n" " [cyan]uninstall[/] Remove a hub-installed skill\n" + " [cyan]list-modified[/] List bundled skills you've edited (kept by update)\n" + " [cyan]diff[/] Diff your copy of a bundled skill vs the stock version\n" " [cyan]reset[/] [--restore] Reset bundled-skill tracking (fix 'user-modified' flag)\n" " [cyan]publish[/] --repo Publish a skill to GitHub via PR\n" " [cyan]snapshot[/] export|import Export/import skill configurations\n" diff --git a/hermes_cli/subcommands/skills.py b/hermes_cli/subcommands/skills.py index 03aa41024cb..589f9842f55 100644 --- a/hermes_cli/subcommands/skills.py +++ b/hermes_cli/subcommands/skills.py @@ -164,6 +164,35 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None: help="Skip confirmation prompt when using --restore", ) + skills_list_modified = skills_subparsers.add_parser( + "list-modified", + help="List bundled skills you've edited (which `hermes update` keeps)", + description=( + "Show the bundled skills whose local copy differs from the version last " + "synced, i.e. the ones `hermes update` reports as user-modified and skips. " + "Use `hermes skills diff ` to see changes and `hermes skills reset " + "` to resume updates." + ), + ) + skills_list_modified.add_argument( + "--json", + action="store_true", + help="Output the list as JSON", + ) + + skills_diff = skills_subparsers.add_parser( + "diff", + help="Show how your copy of a bundled skill differs from the stock version", + description=( + "Print a unified diff between your local copy of a bundled skill and the " + "current bundled (stock) version, so you can confirm what changed before " + "running `hermes skills reset`." + ), + ) + skills_diff.add_argument( + "name", help="Skill name to diff (e.g. google-workspace)" + ) + skills_opt_out = skills_subparsers.add_parser( "opt-out", help="Stop bundled skills from being seeded into this profile", diff --git a/tools/skills_sync.py b/tools/skills_sync.py index d9d6d9d5076..548f0dbb1db 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -30,7 +30,7 @@ from datetime import datetime, timezone from pathlib import Path, PurePosixPath from hermes_constants import get_bundled_skills_dir, get_hermes_home, get_optional_skills_dir from agent.skill_utils import is_excluded_skill_path -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple from utils import atomic_replace logger = logging.getLogger(__name__) @@ -785,6 +785,160 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict: return {"ok": True, "action": action, "message": message, "synced": synced} +def list_user_modified_bundled_skills() -> List[dict]: + """Return the bundled skills that ``hermes update`` keeps because the user + edited them locally. + + A skill counts as user-modified when its on-disk copy no longer matches the + origin hash recorded in the manifest the last time it was synced — the exact + same test the sync loop uses to decide what to skip. This is the discovery + half of that behavior, so a user can find the names the ``~ N user-modified + (kept)`` notice only counts. + + Returns a list (sorted by name) of dicts: + ``{"name": str, "dest": Path, "bundled_src": Path}`` + where ``dest`` is the user's copy and ``bundled_src`` is the current stock + copy (so callers can diff or restore). + """ + manifest = _read_manifest() + if not manifest: + return [] + bundled_dir = _get_bundled_dir() + modified: List[dict] = [] + for skill_name, skill_dir in _discover_bundled_skills(bundled_dir): + origin_hash = manifest.get(skill_name) + # No entry, or a v1 entry not yet baselined (empty hash): not a tracked + # modification — the next sync handles it. + if not origin_hash: + continue + dest = _compute_relative_dest(skill_dir, bundled_dir) + if not dest.exists(): + continue + if _dir_hash(dest) != origin_hash: + modified.append( + {"name": skill_name, "dest": dest, "bundled_src": skill_dir} + ) + modified.sort(key=lambda e: e["name"]) + return modified + + +def _read_text_for_diff(path: Path) -> Optional[str]: + """Return file text for diffing, or ``None`` if the file is binary/unreadable.""" + try: + data = path.read_bytes() + except OSError: + return None + if b"\x00" in data: + return None + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return None + + +def diff_bundled_skill(name: str) -> dict: + """Diff a user's copy of a bundled skill against the current stock version. + + Lets a user see exactly what diverged before deciding whether to keep their + edits or ``hermes skills reset`` back to upstream. + + Returns a dict: + ``ok`` (bool), ``name`` (str), ``found`` (bool — bundled source exists), + ``user_present`` (bool), ``modified`` (bool), ``message`` (str), + ``diffs``: list of ``{"path": str, "status": str, "diff": str}`` where + status is one of ``modified`` / ``added`` (only in user copy) / + ``removed`` (only in bundled) / ``binary``. + """ + import difflib + + bundled_dir = _get_bundled_dir() + bundled_by_name = dict(_discover_bundled_skills(bundled_dir)) + bundled_src = bundled_by_name.get(name) + if bundled_src is None: + return { + "ok": False, + "name": name, + "found": False, + "user_present": False, + "modified": False, + "diffs": [], + "message": ( + f"'{name}' is not a tracked bundled skill (no stock version to " + f"diff against). Hub-installed skills use `hermes skills inspect`." + ), + } + dest = _compute_relative_dest(bundled_src, bundled_dir) + if not dest.exists(): + return { + "ok": False, + "name": name, + "found": True, + "user_present": False, + "modified": False, + "diffs": [], + "message": f"No local copy of '{name}' found at {dest}.", + } + + user_files = { + p.relative_to(dest).as_posix() for p in dest.rglob("*") if p.is_file() + } + stock_files = { + p.relative_to(bundled_src).as_posix() + for p in bundled_src.rglob("*") + if p.is_file() + } + + diffs: List[dict] = [] + for rel in sorted(user_files | stock_files): + in_user = rel in user_files + in_stock = rel in stock_files + user_text = _read_text_for_diff(dest / rel) if in_user else None + stock_text = _read_text_for_diff(bundled_src / rel) if in_stock else None + + if in_user and in_stock: + if user_text is None or stock_text is None: + # At least one side is binary — report only if bytes differ. + if (dest / rel).read_bytes() != (bundled_src / rel).read_bytes(): + diffs.append( + {"path": rel, "status": "binary", "diff": ""} + ) + continue + if user_text == stock_text: + continue + text = "".join( + difflib.unified_diff( + stock_text.splitlines(keepends=True), + user_text.splitlines(keepends=True), + fromfile=f"stock/{rel}", + tofile=f"yours/{rel}", + ) + ) + diffs.append({"path": rel, "status": "modified", "diff": text}) + elif in_user: + diffs.append( + {"path": rel, "status": "added", "diff": f"+ only in your copy: {rel}"} + ) + else: + diffs.append( + {"path": rel, "status": "removed", "diff": f"- only in stock: {rel}"} + ) + + modified = bool(diffs) + return { + "ok": True, + "name": name, + "found": True, + "user_present": True, + "modified": modified, + "diffs": diffs, + "message": ( + f"'{name}' matches the stock version." + if not modified + else f"'{name}' differs from the stock version in {len(diffs)} file(s)." + ), + } + + def set_bundled_skills_opt_out(enabled: bool) -> dict: """Toggle the .no-bundled-skills opt-out marker for the active profile. From 481f0417d837258e2ec22af656b36e87215d0c81 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 17 Jun 2026 17:41:45 +0700 Subject: [PATCH 2/3] test(skills): cover list-modified + diff for bundled skills Exercises the real sync pipeline (no mocked comparison logic): a pristine synced skill is not flagged; an edited one is listed and diffed (modified + added files); an unknown skill returns not-ok; and `reset --restore` clears the modified state so revert and discovery stay consistent. --- tests/tools/test_skills_list_modified_diff.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/tools/test_skills_list_modified_diff.py diff --git a/tests/tools/test_skills_list_modified_diff.py b/tests/tools/test_skills_list_modified_diff.py new file mode 100644 index 00000000000..972b0e103b9 --- /dev/null +++ b/tests/tools/test_skills_list_modified_diff.py @@ -0,0 +1,132 @@ +"""Tests for discovering and diffing user-modified bundled skills. + +`hermes update` keeps (does not overwrite) bundled skills the user edited +locally, but historically only printed a *count* — there was no way to find +which skills, or see what changed. These tests cover the two helpers that close +that gap, exercising the real sync pipeline (no mocks of the comparison logic): + +* ``list_user_modified_bundled_skills()`` — the discovery half of the exact + test the sync loop uses to decide what to skip. +* ``diff_bundled_skill()`` — a unified diff of the user copy vs the stock copy. + +Revert already exists (``reset_bundled_skill``); the last test confirms it +clears the modified state so the two stay consistent. +""" + +from contextlib import ExitStack +from unittest.mock import patch + +from tools.skills_sync import ( + sync_skills, + reset_bundled_skill, + list_user_modified_bundled_skills, + diff_bundled_skill, +) + + +def _make_bundled(tmp_path): + """A fake bundled skills tree with one skill: category/foo.""" + bundled = tmp_path / "bundled_skills" + foo = bundled / "category" / "foo" + foo.mkdir(parents=True) + (foo / "SKILL.md").write_text("---\nname: foo\n---\n# Foo Skill\n") + (foo / "helper.py").write_text("print('stock')\n") + return bundled + + +def _patches(bundled, skills_dir, manifest_file): + stack = ExitStack() + stack.enter_context( + patch("tools.skills_sync._get_bundled_dir", return_value=bundled) + ) + stack.enter_context( + patch( + "tools.skills_sync._get_optional_dir", + return_value=bundled.parent / "optional-skills", + ) + ) + stack.enter_context(patch("tools.skills_sync.SKILLS_DIR", skills_dir)) + stack.enter_context(patch("tools.skills_sync.MANIFEST_FILE", manifest_file)) + return stack + + +def _env(tmp_path): + bundled = _make_bundled(tmp_path) + skills_dir = tmp_path / "user_skills" + manifest_file = skills_dir / ".bundled_manifest" + return bundled, skills_dir, manifest_file + + +def test_pristine_skill_is_not_listed_as_modified(tmp_path): + bundled, skills_dir, manifest_file = _env(tmp_path) + with _patches(bundled, skills_dir, manifest_file): + sync_skills(quiet=True) + assert list_user_modified_bundled_skills() == [] + + +def test_edited_skill_is_listed_as_modified(tmp_path): + bundled, skills_dir, manifest_file = _env(tmp_path) + with _patches(bundled, skills_dir, manifest_file): + sync_skills(quiet=True) + (skills_dir / "category" / "foo" / "helper.py").write_text("print('mine')\n") + + modified = list_user_modified_bundled_skills() + names = [m["name"] for m in modified] + assert names == ["foo"] + entry = modified[0] + assert entry["dest"] == skills_dir / "category" / "foo" + assert entry["bundled_src"] == bundled / "category" / "foo" + + +def test_diff_reports_no_changes_when_pristine(tmp_path): + bundled, skills_dir, manifest_file = _env(tmp_path) + with _patches(bundled, skills_dir, manifest_file): + sync_skills(quiet=True) + result = diff_bundled_skill("foo") + assert result["ok"] is True + assert result["modified"] is False + assert result["diffs"] == [] + + +def test_diff_shows_modified_and_added_files(tmp_path): + bundled, skills_dir, manifest_file = _env(tmp_path) + with _patches(bundled, skills_dir, manifest_file): + sync_skills(quiet=True) + user_foo = skills_dir / "category" / "foo" + (user_foo / "helper.py").write_text("print('mine')\n") + (user_foo / "extra.txt").write_text("local note\n") + + result = diff_bundled_skill("foo") + assert result["ok"] is True + assert result["modified"] is True + + by_path = {d["path"]: d for d in result["diffs"]} + assert by_path["helper.py"]["status"] == "modified" + # The unified diff shows the user's line replacing the stock line. + assert "print('mine')" in by_path["helper.py"]["diff"] + assert "print('stock')" in by_path["helper.py"]["diff"] + # A file only in the user copy is reported as added. + assert by_path["extra.txt"]["status"] == "added" + + +def test_diff_unknown_skill_is_not_ok(tmp_path): + bundled, skills_dir, manifest_file = _env(tmp_path) + with _patches(bundled, skills_dir, manifest_file): + sync_skills(quiet=True) + result = diff_bundled_skill("does-not-exist") + assert result["ok"] is False + assert result["found"] is False + + +def test_reset_clears_modified_state(tmp_path): + """Revert (existing) and discovery (new) must agree: after reset, not modified.""" + bundled, skills_dir, manifest_file = _env(tmp_path) + with _patches(bundled, skills_dir, manifest_file): + sync_skills(quiet=True) + (skills_dir / "category" / "foo" / "helper.py").write_text("print('mine')\n") + assert [m["name"] for m in list_user_modified_bundled_skills()] == ["foo"] + + # Restore from the stock source, then it must no longer be flagged. + result = reset_bundled_skill("foo", restore=True) + assert result["ok"] is True + assert list_user_modified_bundled_skills() == [] From 67779160688529b676a22fca51edec3b06bd00b3 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:28:11 +0530 Subject: [PATCH 3/3] fix(skills): surface list-modified hint on both update paths + disambiguate diff Salvage follow-up to the cherry-picked feat/test commits: - W1: the unpack/install update path in main.py printed the '~ N user-modified (kept)' notice without the new 'hermes skills list-modified' hint that the git-pull path got. Mirror the hint to both sites so the count is actionable regardless of which update path runs. - W2: 'hermes skills diff ' (bundled-vs-stock) now shares the verb with the gateway write-approval 'diff '. The gateway handler's docstring + truncation message pointed users to '/skills diff ' on the CLI, which now resolves a bundled skill by that name instead. Point at the pending JSON file and note the two diff commands are distinct. - Add an invariant test asserting every 'user-modified (kept)' notice in main.py carries the discovery hint (guards sibling drift). --- gateway/slash_commands.py | 12 +++-- hermes_cli/main.py | 4 ++ .../hermes_cli/test_update_modified_notice.py | 50 +++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 tests/hermes_cli/test_update_modified_notice.py diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 92db5b42f0c..bfcef143f44 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2214,7 +2214,9 @@ class GatewaySlashCommandsMixin: stranded). ``diff`` output is truncated for chat bubbles — the full diff lives in - the CLI (``/skills diff ``) and the pending JSON file. + the pending JSON file under ``~/.hermes/pending/skills/``. (Note this is + the write-approval ``diff ``; the CLI also has an unrelated + ``hermes skills diff `` that diffs a bundled skill vs stock.) """ from gateway.run import _hermes_home from hermes_cli.write_approval_commands import handle_pending_subcommand @@ -2252,12 +2254,14 @@ class GatewaySlashCommandsMixin: "(Search/install are CLI-only.)") # Chat bubbles can't hold a full skill diff — truncate and point at - # the real review surfaces. + # the real review surface. (Note: `hermes skills diff ` is a + # *different* command — it diffs a bundled skill against its stock + # version — so we point at the pending JSON file, not that command.) if args and args[0].lower() == "diff" and len(out) > 3000: pending_id = args[1] if len(args) > 1 else "" out = (out[:3000] - + f"\n… (truncated — full diff: `/skills diff {pending_id}` " - f"on the CLI, or ~/.hermes/pending/skills/{pending_id}.json)") + + "\n… (truncated — full diff in " + f"~/.hermes/pending/skills/{pending_id}.json)") return out async def _handle_fast_command(self, event: MessageEvent) -> str: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 376de5bd543..c69cd60b42a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6073,6 +6073,10 @@ def _update_via_zip(args): ) if result.get("user_modified"): print(f" ~ {len(result['user_modified'])} user-modified (kept)") + print( + " → see them: hermes skills list-modified " + "(diff/reset to resume updates)" + ) if result.get("cleaned"): print(f" − {len(result['cleaned'])} removed from manifest") if not result["copied"] and not result.get("updated"): diff --git a/tests/hermes_cli/test_update_modified_notice.py b/tests/hermes_cli/test_update_modified_notice.py new file mode 100644 index 00000000000..37e41c73be4 --- /dev/null +++ b/tests/hermes_cli/test_update_modified_notice.py @@ -0,0 +1,50 @@ +"""Guard: every `hermes update` path that reports user-modified skills must +also tell the user how to find them. + +`hermes update` keeps (does not overwrite) bundled skills the user edited and +prints a ``~ N user-modified (kept)`` count. There are two independent update +code paths in ``hermes_cli/main.py`` that print this notice (the git-pull path +in ``_cmd_update_impl`` and the unpack/install path). Both must point the user +at ``hermes skills list-modified`` so the count is actionable — otherwise, +depending on which path a user hits, they may never learn the discovery command +exists. + +This is an *invariant* test (the two sibling notices must agree), not a literal +snapshot: it asserts the relationship "count line ⇒ discovery hint", so it +keeps holding if the wording is reworded, as long as both sites stay in sync. +""" + +import re +from pathlib import Path + +import hermes_cli.main as main_mod + + +_COUNT_RE = re.compile(r"user-modified \(kept\)") +_HINT_RE = re.compile(r"hermes skills list-modified") + + +def _source_lines() -> list[str]: + return Path(main_mod.__file__).read_text(encoding="utf-8").splitlines() + + +def test_every_user_modified_notice_points_at_list_modified(): + lines = _source_lines() + count_sites = [i for i, ln in enumerate(lines) if _COUNT_RE.search(ln)] + + # There are at least two such notices today; the bug was that only one of + # them carried the discovery hint. Assert each is followed (within a small + # window — the count print + the hint print) by the list-modified pointer. + assert len(count_sites) >= 2, ( + "expected at least two 'user-modified (kept)' notices in main.py; " + f"found {len(count_sites)}" + ) + + for idx in count_sites: + window = "\n".join(lines[idx : idx + 5]) + assert _HINT_RE.search(window), ( + "a 'user-modified (kept)' notice near line " + f"{idx + 1} of main.py does not point users at " + "`hermes skills list-modified` within the following lines — the " + "two update paths have drifted apart again:\n" + window + )