Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ the scores. `novelty` refuses a self-judge; `arms run` does not check, so name
- **Competitors** are the other skills an agent sees next to this one when it
picks a skill to load. For a catalog skill they are its siblings in the
same catalog: `--catalog <dir>` takes every other skill under it (a `template/` copy and a
byte-identical mirror that loads under the same skill name are left out), and
byte-identical mirror that loads under the same skill name are left out, and vendored and
VCS directories such as `.git`, `node_modules`, `site-packages` and virtualenvs are not walked), and
`--competitors skill:<path> …` names them one by one. Routing asks whether
this skill wins the user requests meant for it. If you only have the one
skill, skip step 2 and say so in the review: a number measured against
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- **Vendored skills no longer load** — skill discovery (the `skill-catalog:` arm and the `catalog` commands) does not walk `.git`, `node_modules`, `site-packages`, `.venv`/`venv`, `.tox`/`.nox`, `.hg`/`.svn` or `__pycache__` below the catalog root, so a dependency's copy of a skill is no longer offered as a skill of the catalog.
2 changes: 1 addition & 1 deletion dokimi/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ flowchart LR
| `skill:` | a skill (`SKILL.md`) injected as context |
| `profile:` | an agent persona system prompt |
| `agent:` / `skill-agent:` | **agentic** use — the model gets a tool and *decides* whether to fetch docs / load the skill |
| `skill-catalog:` | **selection** — every skill in a catalog is offered (a copy under a `template/` or `templates/` directory and a byte-identical mirror that loads under the same skill name are left out); scored on which one loads (`catalog trigger-set` builds the queries) |
| `skill-catalog:` | **selection** — every skill in a catalog is offered (a copy under a `template/` or `templates/` directory, a byte-identical mirror that loads under the same skill name, and anything under a vendored or VCS directory such as `.git`, `node_modules` or `.venv` are left out); scored on which one loads (`catalog trigger-set` builds the queries) |

Two fine-print notes:

Expand Down
6 changes: 4 additions & 2 deletions dokimi/docs/evaluating-treatments.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ measure the marginal value of any context-augmentation artifact:
| `skill:<path>` | agent skill | injects a `SKILL.md` as context |
| `agent[:source]` | **agentic** doc use | gives the model a doc-search tool; it decides when to call it |
| `skill-agent:<path>` | **agentic** skill use | offers the skill via progressive disclosure |
| `skill-catalog:<dir>` | **agentic** skill selection | offers every skill under `<dir>` (a `template/` copy and a byte-identical mirror that loads under the same skill name are left out); scores the one loaded against `expected_skill` |
| `skill-catalog:<dir>` | **agentic** skill selection | offers every skill under `<dir>` (a `template/` copy, a byte-identical mirror that loads under the same skill name, and anything under a vendored or VCS directory such as `.git` or `node_modules` are left out); scores the one loaded against `expected_skill` |
| `<spec1>+<spec2>[+...]` | **combined** treatment | merges multiple treatments into one arm (e.g., `docs+skill:path`) |

The last two run through a tool-calling loop
Expand Down Expand Up @@ -290,7 +290,9 @@ artifact per cell, a `matrix_rollup.v1` JSON/Markdown report, and paired
- **`skill-catalog:<dir>`** — skill selection. Every skill under `<dir>` is offered
the same way, so the question is answered among its rivals. A `SKILL.md` under a
`template/` or `templates/` directory, at any depth below `<dir>`, is not a skill and is
skipped (a real skill kept under such a directory is skipped too), and a `SKILL.md`
skipped (a real skill kept under such a directory is skipped too). Vendored and VCS
directories (`.git`, `.hg`, `.svn`, `node_modules`, `site-packages`, `.venv`, `venv`,
`.tox`, `.nox`, `__pycache__`) below `<dir>` are not walked, and no note says so. A `SKILL.md`
byte-identical to another under `<dir>` that loads under the same skill name (a plugin's
copy of `skills/`) is offered once. Questions that carry `expected_skill` are scored
on the skill loaded first; see
Expand Down
3 changes: 2 additions & 1 deletion dokimi/docs/external-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ way the readers expect. Substitute your own checkout path.

Both manifests are optional — with no manifest, the readers fall back to a
plain filesystem scan of the same directories (a `SKILL.md` under a `template/`
or `templates/` directory is a scaffold and is left out). `skills.yaml` comes in two
or `templates/` directory is a scaffold and is left out; vendored and VCS directories such
as `.git`, `node_modules`, `site-packages` and `.venv` are not walked). `skills.yaml` comes in two
shapes, and both hold a *list* of entries:

```yaml
Expand Down
4 changes: 3 additions & 1 deletion dokimi/docs/skill-novelty-walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ python cli.py novelty routing --artifact skill:skills/my-skill \

The competitors are the skills a harness lists next to this one: `--catalog <dir>` takes
every other `SKILL.md` under the directory (a `template/` copy and a byte-identical mirror
that loads under the same skill name are left out), and `--competitors` adds or names them one by
that loads under the same skill name are left out, and vendored and VCS directories such as
`.git`, `node_modules`, `site-packages` and virtualenvs are not walked), and `--competitors`
adds or names them one by
one. With no real siblings at hand, skip this step and say so; a field of competitors made
up for the run measures nothing.

Expand Down
3 changes: 2 additions & 1 deletion dokimi/docs/skill-novelty.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,8 @@ python cli.py novelty routing --artifact skill:path/to/skill \

`--catalog <dir>` takes the competitors from a skill directory instead: every `SKILL.md`
under it except the artifact itself (a `template/` copy and a byte-identical mirror that
loads under the same skill name are left out).
loads under the same skill name are left out, and vendored and VCS directories such as `.git`,
`node_modules`, `site-packages` and virtualenvs are not walked).

Two numbers, reported side by side and never merged:

Expand Down
10 changes: 5 additions & 5 deletions dokimi/dokimi/commands/novelty.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,11 +656,11 @@ def register(sub, positive_int) -> None:
"candidate is not routing: the sibling confusion is the "
"failure mode (every `dpnp-*` skill scored 0.0 lexically)")
routing_p.add_argument("--catalog", default=None,
help="Directory of skills to take the competitors from: every "
"SKILL.md under it except the artifact itself (a template/ "
"copy and a byte-identical mirror that loads under the same "
"skill name are left out), i.e. the siblings a harness "
"lists next to it. Combines with --competitors")
help="Directory of skills to take the competitors from: every SKILL.md "
"under it except the artifact itself (a template/ copy, a byte-"
"identical mirror that loads under the same skill name, and "
"vendored or VCS directories like .git are left out), i.e. the "
"siblings a harness lists next to it. Combines with --competitors")
routing_p.add_argument("--queries", default=None,
help="JSON list (or one per line) of user-shaped requests that "
"should select this artifact, written independently of it")
Expand Down
7 changes: 4 additions & 3 deletions dokimi/dokimi/external/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

from dokimi.harnesses.tasks import TaskSpec, discover_tasks, is_task_dir, load_task
from dokimi.skills import Skill, load_skill
from dokimi.skills.loader import TEMPLATE_DIR_NAMES
from dokimi.skills.loader import TEMPLATE_DIR_NAMES, walk_skill_files

DEFAULT_TASKS_SUBDIR = "evaluation/harbor/tasks"
DEFAULT_SUITES_MANIFEST = "evaluation/harbor/suites.json"
Expand Down Expand Up @@ -99,10 +99,11 @@ def _scan_skill_files(root: Path) -> list[SkillCatalogEntry]:

A skill that does not load is an error row, so one broken skill does not abort the scan.
A ``SKILL.md`` under a ``template``/``templates`` directory is a scaffold and is left
out, as :func:`dokimi.skills.discover_skills` leaves it out.
out, as :func:`dokimi.skills.discover_skills` leaves it out; one under a vendored or
VCS directory is a dependency's copy and is not walked, as there too.
"""
found: list[SkillCatalogEntry] = []
for skill_file in sorted(root.rglob("SKILL.md")):
for skill_file in walk_skill_files(root):
if TEMPLATE_DIR_NAMES.intersection(skill_file.relative_to(root).parts[:-1]):
continue
try:
Expand Down
4 changes: 2 additions & 2 deletions dokimi/dokimi/skills/formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from pathlib import Path
from typing import Any, Dict, List, Optional

from dokimi.skills.loader import skill_files
from dokimi.skills.loader import VENDORED_DIR_NAMES, skill_files
from dokimi.utils import parse_frontmatter

FORMAT_AGENT_SKILL = "agent-skill"
Expand All @@ -60,7 +60,7 @@
(".agents/skills", "agents"), # the cross-tool alias Codex and Gemini CLI read
("environment/skills", "task-bundled"), # a SkillsBench / Harbor task's own skills
)
_SKIP_DIRS = frozenset({".git", "node_modules", "__pycache__", ".venv"})
_SKIP_DIRS = VENDORED_DIR_NAMES # the same dependency and VCS dirs discover_skills does not walk


@dataclass
Expand Down
27 changes: 25 additions & 2 deletions dokimi/dokimi/skills/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""

import hashlib
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
Expand All @@ -20,6 +21,10 @@

# Directory names that hold a scaffold to copy, not a skill to use (see discover_skills).
TEMPLATE_DIR_NAMES = frozenset({"template", "templates"})
# Directory names that hold version control, installed dependencies or virtualenvs: a
# SKILL.md there is a vendored copy of someone else's skill, not one of the catalog's.
VENDORED_DIR_NAMES = frozenset({".git", ".hg", ".svn", "node_modules", "site-packages",
".venv", "venv", ".tox", ".nox", "__pycache__"})
Comment thread
antonwolfy marked this conversation as resolved.
Resolved


@dataclass
Expand Down Expand Up @@ -154,11 +159,28 @@ def load_skill(path) -> Skill:
)


def walk_skill_files(root: Path) -> List[Path]:
"""Every entry named ``SKILL.md`` below *root*, sorted, without walking :data:`VENDORED_DIR_NAMES`.

A dangling symlink or a directory is kept: ``catalog scan`` reports it as an error row.
"""
found: List[Path] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in VENDORED_DIR_NAMES]
if "SKILL.md" in filenames or "SKILL.md" in dirnames:
found.append(Path(dirpath) / "SKILL.md")
return sorted(found)


def discover_skills(root, *, notes: Optional[List[str]] = None) -> List[Skill]:
"""Load every skill under *root* (each subdirectory with a ``SKILL.md``).

Two kinds of ``SKILL.md`` are not skills of *root* and are left out:
Three kinds of ``SKILL.md`` are not skills of *root* and are left out:

- one under a version-control, dependency or virtualenv directory
(:data:`VENDORED_DIR_NAMES`: ``.git``, ``node_modules``, ``site-packages``,
``.venv`` ...) below *root*, a vendored copy of a package's skill that would
otherwise load as a skill of its own. Those directories are not walked;
- one under a ``template`` or ``templates`` directory at any depth below *root*, a
scaffold to copy (anthropics/skills keeps a blank one at ``template/SKILL.md``).
A real skill kept under such a directory is left out too;
Expand All @@ -171,6 +193,7 @@ def discover_skills(root, *, notes: Optional[List[str]] = None) -> List[Skill]:
Args:
root: Directory to scan.
notes: When given, one line per skipped template or mirror is appended to it.
Vendored directories are skipped without a note.

Returns:
The skills, in path order.
Expand All @@ -182,7 +205,7 @@ def skill_files(root, *, notes: Optional[List[str]] = None) -> List[Path]:
"""The ``SKILL.md`` files :func:`discover_skills` loads, in path order, unloaded."""
root = Path(root)
found: List[Path] = []
for skill_file in sorted(root.rglob("SKILL.md")):
for skill_file in walk_skill_files(root):
if not skill_file.is_file():
continue
if TEMPLATE_DIR_NAMES.intersection(skill_file.relative_to(root).parts[:-1]):
Expand Down
22 changes: 22 additions & 0 deletions dokimi/tests/test_catalog_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,28 @@ def test_catalog_scan_exits_nonzero_on_load_error(tmp_path, capsys):
assert exc_info.value.code == 1


@pytest.mark.parametrize("broken", ["dangling-symlink", "directory"])
def test_catalog_scan_exits_nonzero_on_a_skill_md_that_is_not_a_file(tmp_path, capsys, broken):
# discover_skills leaves such a SKILL.md out; the bare scan must still report it.
_write_skill(tmp_path, "good")
bad = tmp_path / "skills" / "bad" / "SKILL.md"
bad.parent.mkdir(parents=True)
if broken == "directory":
bad.mkdir()
else:
try:
bad.symlink_to(tmp_path / "missing.md")
except OSError: # Windows without Developer Mode or the symlink privilege
pytest.skip("this OS refuses an unprivileged symlink")

parser = build_parser()
args = parser.parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", "skills"])
with pytest.raises(SystemExit) as exc_info:
args.func(args)
assert exc_info.value.code == 1
assert "bad" in capsys.readouterr().out


def test_catalog_scan_exits_nonzero_on_a_task_that_exists_but_cannot_load(tmp_path, capsys):
# The tasks-half sibling of the test above. Both halves need their own case:
# skills failures carry an `error`, task failures a `failed` flag, and the
Expand Down
9 changes: 9 additions & 0 deletions dokimi/tests/test_external_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,15 @@ def test_read_skill_manifest_falls_back_to_bare_scan_without_manifest(tmp_path):
assert entries[0].status is None


@pytest.mark.parametrize("vendored", ["node_modules/pkg", ".venv/lib/python3.12/site-packages/pkg"])
def test_read_skill_manifest_bare_scan_skips_vendored_copies(tmp_path, vendored):
_write_skill(tmp_path, "bare-skill")
_write_skill(tmp_path / vendored, "bare-skill")

entries = read_skill_manifest(tmp_path)
assert [e.path for e in entries] == [tmp_path / "skills" / "bare-skill"]


# ── suites.json manifest ──────────────────────────────────────────────────────


Expand Down
10 changes: 9 additions & 1 deletion dokimi/tests/test_skill_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,18 @@ def test_each_ecosystem_is_named_with_how_it_is_selected(tmp_path):
def test_every_loadable_package_is_one_the_skill_loader_loads(tmp_path):
root = _tree(tmp_path)
loadable = {p.name for p in sf.detect_skill_packages(root) if p.loadable}
# The loader does not skip node_modules/ (a vendored copy loads as a skill); the scan does.
# node_modules/ is a vendored directory: the loader and the scan both skip it.
assert loadable == {s.name for s in discover_skills(root)} - {"x"}


def test_a_vendored_skill_is_not_listed_as_a_package(tmp_path):
_skill(tmp_path / "skills/a", "a")
_skill(tmp_path / "lib/site-packages/pkg/skills/b", "b")
_skill(tmp_path / "venv/skills/c", "c")
paths = [p.path.relative_to(tmp_path).as_posix() for p in sf.detect_skill_packages(tmp_path)]
assert paths == ["skills/a/SKILL.md"]


def test_a_malformed_manifest_is_a_problem_not_a_crash(tmp_path):
for value in (5, True, "abc"):
root = tmp_path / str(value)
Expand Down
48 changes: 48 additions & 0 deletions dokimi/tests/test_skill_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,46 @@ def test_discovery_skips_a_template_directory(tmp_path, dirname):
assert [s.name for s in discover_skills(root)] == ["linux-perf"]


@pytest.mark.parametrize("vendored", ["node_modules/pkg", ".git/modules/sub", ".venv/lib/python3.12/site-packages/pkg"])
def test_discovery_skips_vendored_copies(tmp_path, vendored):
Comment thread
antonwolfy marked this conversation as resolved.
Resolved
root = tmp_path / "repo"
_write_skill(root / "skills" / "linux-perf", "linux-perf", "Profile Linux programs.")
# A different version of the same skill, so the mirror rule would not drop it.
_write_skill(root / vendored / "skills" / "linux-perf", "linux-perf", "Profile programs, v0.")
_write_skill(root / vendored / "skills" / "other", "other", "Someone else's skill.")
notes: list[str] = []
assert [s.name for s in discover_skills(root, notes=notes)] == ["linux-perf"]
assert notes == []


@pytest.mark.parametrize(
"name",
[
".git",
".hg",
".svn",
"node_modules",
"site-packages",
".venv",
"venv",
".tox",
".nox",
"__pycache__",
],
)
def test_discovery_skips_each_vendored_name(tmp_path, name):
root = tmp_path / "repo"
_write_skill(root / "skills" / "a", "a", "Skill a.")
_write_skill(root / "skills" / "a" / name / "pkg", "b", "Vendored b.")
assert [s.name for s in discover_skills(root)] == ["a"]


def test_discovery_walks_a_root_inside_a_vendored_directory(tmp_path):
root = tmp_path / "node_modules" / "pkg" / "skills"
_write_skill(root / "a", "a", "Skill a.")
assert [s.name for s in discover_skills(root)] == ["a"]


def test_discovery_reports_a_skipped_template(tmp_path):
root = tmp_path / "catalog"
_write_skill(root / "template", "template-skill", "Replace with description of the skill.")
Expand Down Expand Up @@ -263,6 +303,14 @@ def test_discovery_ignores_a_directory_named_skill_md(tmp_path):
assert [s.name for s in discover_skills(root)] == ["linux-perf"]


def test_discovery_ignores_a_dangling_skill_md_symlink(tmp_path):
root = tmp_path / "catalog"
(root / "broken").mkdir(parents=True)
(root / "broken" / "SKILL.md").symlink_to(tmp_path / "missing.md")
_write_skill(root / "linux-perf", "linux-perf", "Profile Linux programs.")
assert [s.name for s in discover_skills(root)] == ["linux-perf"]


def test_arms_run_exits_1_with_a_message_when_skill_md_is_a_directory(tmp_path, monkeypatch, capsys):
from dokimi import cli

Expand Down
Loading