From 15b8804601827414833f7dc03808553791f70ee8 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 30 Oct 2026 11:03:02 +0800 Subject: [PATCH] emrg: read the three ecosystem skill roots in a stated order --- emrg/skills/loader.py | 244 ++++++++++++++++++----- tests/test_skills_read_order.py | 338 ++++++++++++++++++++++++++++++++ 2 files changed, 530 insertions(+), 52 deletions(-) create mode 100644 tests/test_skills_read_order.py diff --git a/emrg/skills/loader.py b/emrg/skills/loader.py index 577753cf..0b39337c 100644 --- a/emrg/skills/loader.py +++ b/emrg/skills/loader.py @@ -1,15 +1,25 @@ """Skill loader — parse Markdown skills with YAML frontmatter. -Skills are filesystem-based Markdown packages. They live in two roots: - - - ~/.emrg/skills/ (user skills) - - /.emrg/skills/ (agent skills) - -and each root is read in **both** layouts, `.md` and `/SKILL.md` -(`_skill_files_in`). `` is the nearest ancestor of the *session's* -working directory carrying a `.git` (`_agent_root`) — not the process's cwd, -which for the daemon is `~/.emrg` and would give every session one agent's -skills. +Skills are filesystem-based Markdown packages. They are read from four roots, **lowest +precedence first**, because that is where the ecosystem actually installs them: + + 1. ~/.claude/skills (label "claude") + 2. ~/.agents/skills (label "agents") + 3. ~/.emrg/skills (label "user", from `config_dir()`) + 4. /.emrg/skills (label "agent") + +A later root's declaration of a name replaces an earlier one's, so an agent's own copy is +the one in effect and the host's instance copy beats both provider directories. A root the +host does not have is skipped without disturbing the others. The list itself is +`default_skill_roots()`, and every reader takes it as a parameter defaulting to that list — +so a test states the roots it means instead of inheriting whatever is installed on the +machine running it. + +Each root is read in **both** layouts, `.md` and `/SKILL.md` +(`_skill_files_in`), and the bundle wins over a flat file of the same name inside one root. +`` is the nearest ancestor of the *session's* working directory carrying a `.git` +(`_agent_root`) — not the process's cwd, which for the daemon is `~/.emrg` and would give +every session one agent's skills. Nothing here is cached: every `load_skills` call re-reads the files, so a skill written, edited or deleted on disk reaches the next prompt without a restart. @@ -22,7 +32,9 @@ # Markdown body with instructions The loader uses a minimal hand-written frontmatter parser to avoid -adding a YAML dependency for the simple key-value format. +adding a YAML dependency for the simple key-value format. It reads quoted and plain +values and the block scalars `>`/`>-`/`|`/`|-` (and their `+` forms), because the skills +published into the provider directories write `description:` that way. Progressive disclosure: only the skill name + description are injected into the system prompt. The LLM uses the read tool to load the full @@ -34,7 +46,7 @@ import logging from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Optional, Sequence # The "is this path a regular file" rule is the tool layer's, reached at a path. Imported # rather than restated, for the reason `emrg/memory.py` gives at the same import: the @@ -56,13 +68,18 @@ class Skill: description: str path: Path # source file path body: str # markdown body (after frontmatter) - source: str # "user" or "agent" + source: str # the root it came from: "claude", "agents", "user" or "agent" @dataclass class SkillShadow: """One declaration of a name that another declaration had already taken. + ``kept`` is the declaration that survived *every* root, not whichever one happened to + overtake this loser: a name carried by `~/.claude`, `~/.agents` and `~/.emrg` yields two + shadows, and both point at `~/.emrg`'s copy — the one the prompt's list above them + offers, and so the one a reader should edit. + The two layers of this loader key a skill by different things: `_skill_files_in` selects candidate *files* by filename, while `_skills_under` files the parsed result under the **declared** `name:`. Two files that declare one name therefore @@ -79,30 +96,92 @@ class SkillShadow: dropped: Skill # the declaration that lost the name +# Every indicator a YAML block scalar can carry. The set is closed on purpose: a value that +# is one of these opens a block, and anything else is the value itself, so a description +# reading `>` is the one shape that cannot be told from its own text without this list. +_BLOCK_SCALARS = ("|", "|-", "|+", ">", ">-", ">+") + + +def _join_block_scalar(indicator: str, lines: list[str]) -> str: + """The real text under a block-scalar key, from the indented lines that follow it. + + Two shapes, and the difference is what the indicator is for: ``|`` is literal and keeps + its line breaks, ``>`` is folded and joins each run of lines into one paragraph with a + blank line between paragraphs. A trailing ``-`` strips the final newline and ``+`` + keeps the trailing blank lines; the bare forms behave like ``-`` here, because this text + is a description rather than a file and a trailing newline in it would be noise in a + one-line prompt entry. + + The indentation removed is the block's own, taken from its least-indented non-blank + line, so a block indented by two spaces and one indented by four read alike. + """ + body = list(lines) + while body and not body[0].strip(): + body.pop(0) + if not indicator.endswith("+"): + while body and not body[-1].strip(): + body.pop() + indents = [len(line) - len(line.lstrip()) for line in body if line.strip()] + cut = min(indents) if indents else 0 + stripped = [line[cut:] if line.strip() else "" for line in body] + + if indicator.startswith(">"): + paragraphs: list[str] = [] + current: list[str] = [] + for line in stripped: + if line.strip(): + current.append(line.strip()) + elif current: + paragraphs.append(" ".join(current)) + current = [] + if current: + paragraphs.append(" ".join(current)) + return "\n".join(paragraphs) + return "\n".join(stripped).rstrip("\n") + + def _parse_frontmatter(text: str) -> dict[str, str]: """Parse simple key: value YAML frontmatter. - Handles quoted strings and plain values. No nested structures. - Indented (nested) lines are ignored so a skill whose frontmatter also - carries a nested metadata list (skill-catalog.md, rant - 2026-08-08T10:14:29) keeps its own top-level name/description — - nested ``description:`` keys must not overwrite the top-level one. - This avoids adding pyyaml as a dependency for the simple format. + Handles quoted strings, plain values and block scalars (``>``, ``>-``, ``|``, ``|-`` + and their ``+`` forms). No nested structures: an indented line outside a block scalar is + nested YAML and is ignored, so a skill whose frontmatter also carries a nested metadata + list (skill-catalog.md, rant 2026-08-08T10:14:29) keeps its own top-level + name/description — nested ``description:`` keys must not overwrite the top-level one. + + Block scalars are read because that is how the skills published into the provider + directories write their description (rant 2026-10-30T10:42:59): the earlier reader + stored the indicator as the value and skipped every line of the real text, so such a + skill was described by the two characters ``>-``. """ result: dict[str, str] = {} - for line in text.split("\n"): - if line[:1].isspace(): # indented → nested YAML, not a top-level key + lines = text.split("\n") + index = 0 + while index < len(lines): + raw = lines[index] + index += 1 + if raw[:1].isspace(): # indented → nested YAML, or a block already consumed continue - line = line.strip() + line = raw.strip() if not line or line.startswith("#"): continue - if ":" in line: - key, _, value = line.partition(":") - key = key.strip() - value = value.strip() - # Strip surrounding quotes - if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): - value = value[1:-1] + if ":" not in line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if value in _BLOCK_SCALARS: + block: list[str] = [] + while index < len(lines): + following = lines[index] + if following.strip() and not following[:1].isspace(): + break # the next top-level key ends the block + block.append(following) + index += 1 + value = _join_block_scalar(value, block) + elif len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + if key: result[key] = value return result @@ -208,6 +287,37 @@ def _user_skills_root() -> Path: return config_dir() / "skills" +# The label each root reports under. The three default roots keep *separate* labels +# because the prompt has to say which one a skill came from, and "user" alone cannot tell a +# `~/.claude/skills` entry from a `~/.emrg/skills` one (rant 2026-10-30T10:42:59, item 7). +ROOT_CLAUDE = "claude" +ROOT_AGENTS = "agents" +ROOT_USER = "user" +ROOT_AGENT = "agent" + + +def default_skill_roots() -> list[tuple[str, Path]]: + """The three instance-wide roots and their labels, **lowest precedence first**. + + They hang off ``config_dir()``'s parent rather than a second ``Path.home()`` call, and + that is load-bearing rather than tidy: `config_dir()` *is* ``~/.emrg``, so its parent is + the home the other two sit in — one derivation, so a host that redirects the instance + root redirects all three at once. That is what keeps a suite honest about the fourth + argument of this rule: measured on the development host, `~/.claude/skills` holds 28 + skills and `~/.agents/skills` 27 of the same names, so a test that inherited them would + answer one way here and another way on a runner with none. + + The agent's own root is deliberately *not* in this list: it depends on the session, and + `load_skills_with_shadows` reads it last so that it beats all three. + """ + home = config_dir().parent + return [ + (ROOT_CLAUDE, home / ".claude" / "skills"), + (ROOT_AGENTS, home / ".agents" / "skills"), + (ROOT_USER, _user_skills_root()), + ] + + def _skill_files_in(root: Path) -> list[Path]: """Every skill file one root carries, in both layouts the ecosystem uses. @@ -253,56 +363,86 @@ def _skill_files_in(root: Path) -> list[Path]: def _skills_under( - root: Path, source: str, into: dict[str, Skill], shadows: list[SkillShadow] + root: Path, source: str, into: dict[str, Skill], dropped: dict[str, list[Skill]] ) -> None: - """Read one root into ``into``, the later root winning on a shared name. - - A skill that arrives under a name already taken replaces the earlier one **and** - records the pair in ``shadows``: the losing declaration is a fact about this tree, - and the caller that renders the prompt is the only reader able to say so. + """Read one root into ``into``, a later declaration of a name replacing an earlier one. + + Every declaration that loses its name is appended to ``dropped`` under that name — + deliberately *not* paired with the declaration that beat it at that moment. With two + roots the two shapes agree; with three they do not. Reading `~/.claude` → `~/.agents` → + `~/.emrg`, a name carried by all three is displaced twice, and a per-step pairing + reports `~/.agents`' copy as "in effect" while the list above the paragraph offers + `~/.emrg`'s — one prompt contradicting itself about which file a reader should edit. + `load_skills_with_shadows` therefore pairs each loss with the declaration that actually + survived, once every root has been read. """ for skill_file in _skill_files_in(root): skill = _parse_skill_file(skill_file, source) if skill: previous = into.get(skill.name) if previous is not None: - shadows.append(SkillShadow(name=skill.name, kept=skill, dropped=previous)) + dropped.setdefault(skill.name, []).append(previous) into[skill.name] = skill logger.debug("skill loaded: %s (%s) from %s", skill.name, source, skill_file) def load_skills_with_shadows( agent_dir: Optional[Path] = None, + roots: Optional[Sequence[tuple[str, Path]]] = None, ) -> tuple[list[Skill], list[SkillShadow]]: """Load every skill, plus every declaration that lost its name to another. - The same read `load_skills` makes — the same two roots, the same live re-read, the - same documented precedence (a name carried by both roots: the agent's copy wins) — - with the merge's discarded half returned instead of dropped. Only a caller that - renders a prompt has to think about the second list; everything that just asks - "which skills are there" keeps calling `load_skills`. + The roots are read **in the order given**, and a later root's declaration of a name + replaces an earlier one's — which is why the list is ordered lowest precedence first. + `default_skill_roots()` puts `~/.claude/skills` before `~/.agents/skills` before + `~/.emrg/skills`, and the agent's own directory is read last whatever the list says, so + the agent always beats the three instance-wide roots. A root that is absent is skipped + and the rest are unaffected. + + ``roots`` is how a caller — and above all a test — states the chain it means. Its + default is the real one; passing a list means the host's own directories are never + opened, which is the difference between measuring this rule and measuring the machine. + + The merge's discarded half is returned rather than dropped: only a caller that renders + a prompt has to think about the second list, and everything that just asks "which skills + are there" keeps calling `load_skills`. """ start = Path(agent_dir) if agent_dir is not None else Path.cwd() agent_root = _agent_root(start) all_skills: dict[str, Skill] = {} - shadows: list[SkillShadow] = [] - user_root = _user_skills_root() - if user_root.is_dir(): - _skills_under(user_root, "user", all_skills, shadows) + displaced: dict[str, list[Skill]] = {} + for label, root in default_skill_roots() if roots is None else roots: + root_path = Path(root) + if root_path.is_dir(): + _skills_under(root_path, label, all_skills, displaced) agent_skills_root = agent_root / ".emrg" / "skills" if agent_skills_root.is_dir(): - _skills_under(agent_skills_root, "agent", all_skills, shadows) - + _skills_under(agent_skills_root, ROOT_AGENT, all_skills, displaced) + + # Paired here rather than at each merge step: a name carried by three roots loses two + # declarations, and both were beaten by the same survivor, not by each other. + shadows = [ + SkillShadow(name=name, kept=all_skills[name], dropped=loser) + for name, losers in displaced.items() + for loser in losers + ] return sorted(all_skills.values(), key=lambda s: s.name), shadows -def load_skills(agent_dir: Optional[Path] = None) -> list[Skill]: - """Load every skill from the user root and one agent root, read live. - - User skills (``~/.emrg/skills/``) are read first, then the agent's - (``/.emrg/skills/``); a skill of the same name: the agent's wins. +def load_skills( + agent_dir: Optional[Path] = None, + roots: Optional[Sequence[tuple[str, Path]]] = None, +) -> list[Skill]: + """Load every skill from the three instance roots and one agent root, read live. + + The instance roots are `default_skill_roots()` — ``~/.claude/skills``, then + ``~/.agents/skills``, then ``~/.emrg/skills`` — and the agent's + (``/.emrg/skills/``) is read last, so of two skills sharing a name the later + root's wins: an agent copy beats the instance copy, which beats both provider + directories. ``roots`` supplies a different chain, which is how a test avoids reading + the host's own directories. ``agent_dir`` is the **session's** working directory — deliberately not the process's, which for the daemon is whatever it was started in and made every diff --git a/tests/test_skills_read_order.py b/tests/test_skills_read_order.py new file mode 100644 index 00000000..2e64ef15 --- /dev/null +++ b/tests/test_skills_read_order.py @@ -0,0 +1,338 @@ +"""The four skill roots, the order they are read in, and the descriptions inside them. + +Issue #2654. The loader read two roots — the instance's ``~/.emrg/skills`` and the agent's +``/.emrg/skills`` — so a skill installed into either provider directory +(``~/.claude/skills``, ``~/.agents/skills``), which is where the ecosystem actually publishes +them, was not loaded at all and had to be copied by hand. Where the same name existed in two +places there was also no rule: the one collision the module knew about was agent-beats-user, +and every other overlap was decided by whichever directory happened to be read last. + +Two properties hold the whole file together. + +**Every test states its own roots.** `load_skills_with_shadows(..., roots=[...])` is the +parameter that exists for this, and the reason is measured rather than stylistic: the +development host carries 28 skills in ``~/.claude/skills`` and 27 of the same names in +``~/.agents/skills``, so a suite that read the real directories would answer one way here and +another way on a CI runner with none. A test that inherited them would be measuring the +machine, not this rule. + +**The last root read wins.** Precedence is *position*, not a comparison of labels, so the +tests below assert which root's copy is in effect and which one is reported as shadowed — +the pair the prompt and the ``/skills`` screen both render. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from emrg.skills import loader as loader_mod +from emrg.skills.loader import ( + build_skills_context, + default_skill_roots, + load_skills_with_shadows, +) + +SKILL_TEXT = "---\nname: {name}\ndescription: {description}\n---\n\nbody\n" + + +def _skill(name: str, description: str = "a skill") -> str: + return SKILL_TEXT.format(name=name, description=description) + + +def _write_md(root: Path, name: str, description: str) -> Path: + """One flat ``.md`` skill in ``root``, the layout an installer publishes.""" + root.mkdir(parents=True, exist_ok=True) + path = root / f"{name}.md" + path.write_text(_skill(name, description), encoding="utf-8") + return path + + +def _write_bundle(root: Path, bundle: str, name: str, description: str) -> Path: + """One ``/SKILL.md`` skill, the layout that can carry files beside it.""" + directory = root / bundle + directory.mkdir(parents=True, exist_ok=True) + path = directory / "SKILL.md" + path.write_text(_skill(name, description), encoding="utf-8") + return path + + +@pytest.fixture +def roots(tmp_path): + """Three instance roots and one agent directory, all inside ``tmp_path``. + + Nothing here resolves a real home: the three roots are handed to the loader as its + ``roots`` argument, and the agent root comes from ``_agent_root`` finding the ``.git`` + this fixture creates. The ``.emrg`` name on the instance root is deliberate — it is the + layout a host has, and the loader's own default derivation is exercised separately. + """ + base = tmp_path / "tree" + agent = tmp_path / "agent" + agent.mkdir(parents=True, exist_ok=True) + (agent / ".git").mkdir(exist_ok=True) + instance = { + "claude": base / "claude", + "agents": base / "agents", + "user": base / "emrg", + } + return { + "dirs": instance, + "agent": agent, + "agent_skills": agent / ".emrg" / "skills", + "roots": [ + ("claude", instance["claude"]), + ("agents", instance["agents"]), + ("user", instance["user"]), + ], + } + + +def _load(tree): + return load_skills_with_shadows(agent_dir=tree["agent"], roots=tree["roots"]) + + +def test_all_three_default_roots_declaring_one_name_resolve_to_the_instance_copy(roots): + """The staircase's top step: three copies, one in effect, two named as displaced. + + `~/.emrg/skills` is read last of the three, so its copy is the one offered — and the + other two are not silently gone: each is reported with the root it came from and the + file it lives in, which is what lets a host see that a provider copy exists and was + overridden rather than wonder why editing it changed nothing. + """ + name = "shared" + files = { + label: _write_md(directory, name, f"from {label}") + for label, directory in roots["dirs"].items() + } + + skills, shadows = _load(roots) + + assert [s.name for s in skills] == [name], skills + assert skills[0].description == "from user", skills[0].description + assert skills[0].source == "user" + assert skills[0].path == files["user"] + + assert len(shadows) == 2, shadows + by_dropped = {shadow.dropped.source: shadow for shadow in shadows} + assert set(by_dropped) == {"claude", "agents"}, by_dropped + for label, shadow in by_dropped.items(): + assert shadow.name == name + assert shadow.kept.source == "user" + assert shadow.kept.path == files["user"] + assert shadow.dropped.path == files[label], ( + f"the displaced {label} declaration is not named by its own file" + ) + + +def test_the_agents_directory_wins_over_the_claude_directory(roots): + """The first two steps on their own: `~/.agents/skills` is read after `~/.claude/skills`.""" + name = "shared" + claude_file = _write_md(roots["dirs"]["claude"], name, "from claude") + agents_file = _write_md(roots["dirs"]["agents"], name, "from agents") + + skills, shadows = _load(roots) + + assert [s.name for s in skills] == [name], skills + assert skills[0].source == "agents" + assert skills[0].path == agents_file + assert skills[0].description == "from agents" + assert [(s.kept.path, s.dropped.path) for s in shadows] == [(agents_file, claude_file)] + + +@pytest.mark.parametrize("label", ["claude", "agents", "user"]) +def test_a_name_in_one_root_loads_and_names_that_root(roots, label): + """No collision: the skill loads, and its entry says which of the three it came from. + + The label is the point of the row. A single ``user`` label for all three would pass a + test that only asked whether the skill loaded, which is exactly the reading the three + roots were added to make possible. + """ + path = _write_md(roots["dirs"][label], "solo", f"from {label}") + + skills, shadows = _load(roots) + + assert [s.name for s in skills] == ["solo"], skills + assert skills[0].source == label + assert skills[0].path == path + assert skills[0].description == f"from {label}" + assert shadows == [], shadows + + +def test_a_root_that_does_not_exist_is_skipped_and_the_others_still_load(roots): + """Two of the three roots absent, the third present: one skill, no error, no shadow. + + A missing directory is the normal state of a host that has never used one of the + provider layouts, so the answer is the other roots' skills rather than an exception or + an empty list. + """ + assert not roots["dirs"]["claude"].exists() + assert not roots["dirs"]["agents"].exists() + _write_md(roots["dirs"]["user"], "only-here", "the survivor") + + skills, shadows = _load(roots) + + assert [s.name for s in skills] == ["only-here"], skills + assert shadows == [] + + +def test_a_bundle_beats_a_flat_file_in_the_same_root(roots): + """Both layouts of one name inside one root: the bundle is the copy in effect. + + The bundle is the form that can carry the files a skill references, so the flat file + beside it is a leftover copy of the same skill. Read within one root, which is why this + is a property of `_skill_files_in` rather than of the root order. + """ + unrelated = _write_md(roots["dirs"]["user"], "same", "the flat copy") + bundle = _write_bundle(roots["dirs"]["user"], "same", "same", "the bundle copy") + + skills, shadows = _load(roots) + + assert [s.name for s in skills] == ["same"], skills + assert skills[0].path == bundle, "the flat file beside a bundle won §5 of issue #2654" + assert skills[0].path != unrelated + assert skills[0].description == "the bundle copy" + assert skills[0].source == "user" + + +def test_the_agent_copy_beats_a_default_root_and_the_shadow_names_which_one(roots): + """The agent tier is read last of all, and says which default root's copy it displaced. + + This is the precedence the module already documented, kept — what is new is that the + loser is named *by its root*, so a reader can tell a displaced provider copy from a + displaced instance copy. + """ + name = "deploy" + user_file = _write_md(roots["dirs"]["user"], name, "the instance copy") + roots["agent_skills"].mkdir(parents=True) + agent_file = roots["agent_skills"] / f"{name}.md" + agent_file.write_text(_skill(name, "the agent copy"), encoding="utf-8") + + skills, shadows = _load(roots) + + assert [s.name for s in skills] == [name], skills + assert skills[0].source == "agent" + assert skills[0].path == agent_file + assert skills[0].description == "the agent copy" + assert len(shadows) == 1, shadows + shadow = shadows[0] + assert shadow.kept.source == "agent" and shadow.kept.path == agent_file + assert shadow.dropped.source == "user" and shadow.dropped.path == user_file + + +BLOCK_SCALAR_CASES = [ + ("folded-strip", ">-", "first line second line"), + ("folded", ">", "first line second line"), + ("literal-strip", "|-", "first line\nsecond line"), + ("literal", "|", "first line\nsecond line"), +] + + +@pytest.mark.parametrize("name, indicator, expected", BLOCK_SCALAR_CASES) +def test_a_block_scalar_description_reads_as_its_text(roots, name, indicator, expected): + """`>`, `>-`, `|` and `|-` describe the skill; the indicator never becomes the text. + + The provider directories' skills write their description this way, and the earlier + reader stored the indicator verbatim and skipped every indented line — so such a skill + was described by the two characters ``>-`` and its real text existed nowhere in the + prompt. Both halves are asserted: the text is there, and the indicator is not. + """ + root = roots["dirs"]["claude"] + root.mkdir(parents=True, exist_ok=True) + path = root / f"{name}.md" + path.write_text( + "---\n" + f"name: {name}\n" + f"description: {indicator}\n" + " first line\n" + " second line\n" + "---\n\nbody\n", + encoding="utf-8", + ) + + skills, _ = _load(roots) + + assert [s.name for s in skills] == [name], skills + description = skills[0].description + assert description == expected, f"{indicator} read as {description!r}" + assert indicator not in description, "the indicator itself became the description" + assert "first line" in description and "second line" in description, ( + "a line of the block scalar was dropped" + ) + + +def test_a_colon_in_a_plain_description_is_kept(roots): + """The control for the block-scalar branch: an ordinary value is not read as one.""" + root = roots["dirs"]["user"] + root.mkdir(parents=True, exist_ok=True) + (root / "plain.md").write_text( + '---\nname: plain\ndescription: "One thing: and another"\n---\n\nbody\n', + encoding="utf-8", + ) + + skills, _ = _load(roots) + + assert [s.name for s in skills] == ["plain"], skills + assert skills[0].description == "One thing: and another" + + +def test_a_nested_description_key_still_loses_to_the_top_level_one(roots): + """The nested-YAML rule the block-scalar reader had to keep, not trade away. + + A skill whose frontmatter carries a nested metadata list must keep its top-level + name/description: an indented ``description:`` is nested YAML, not this file's value. + """ + root = roots["dirs"]["user"] + root.mkdir(parents=True, exist_ok=True) + (root / "nested.md").write_text( + "---\n" + "name: nested\n" + "metadata:\n" + " description: the nested one\n" + "description: the top-level one\n" + "---\n\nbody\n", + encoding="utf-8", + ) + + skills, _ = _load(roots) + + assert [s.name for s in skills] == ["nested"], skills + assert skills[0].description == "the top-level one" + + +def test_the_rendered_entry_names_the_root_the_skill_came_from(roots): + """The prompt carrier: three distinct labels, not one `user` for all three roots.""" + for label, directory in roots["dirs"].items(): + _write_md(directory, f"only-{label}", f"from {label}") + + skills, shadows = _load(roots) + rendered = build_skills_context(skills, shadows) + + for label in roots["dirs"]: + assert f"**only-{label}** ({label}, " in rendered, ( + f"the {label} root's skill is not labelled with its own root: {rendered}" + ) + + +def test_the_default_roots_are_the_three_beside_the_instance_root(tmp_path, monkeypatch): + """The real chain, and the one fact that keeps a suite off the host's own directories. + + `config_dir()` is ``~/.emrg``, so its parent is the home the three roots sit in — one + derivation means a host (or a test) that redirects the instance root redirects all + three. Asserted on a redirected instance root: the roots follow it, and none of them + points at the real home. + """ + instance_root = tmp_path / "home" / ".emrg" + instance_root.mkdir(parents=True) + monkeypatch.setattr(loader_mod, "config_dir", lambda: instance_root) + + actual = default_skill_roots() + + assert [label for label, _ in actual] == ["claude", "agents", "user"], actual + assert [path for _, path in actual] == [ + tmp_path / "home" / ".claude" / "skills", + tmp_path / "home" / ".agents" / "skills", + instance_root / "skills", + ], actual + for _, path in actual: + assert str(tmp_path) in str(path), f"{path} is not under the redirected home"