Skip to content

fix(skill): scope-group the skills system prompt and honor project overrides#2044

Merged
RealKai42 merged 8 commits into
mainfrom
kaiyi/vienna-v1
Apr 24, 2026
Merged

fix(skill): scope-group the skills system prompt and honor project overrides#2044
RealKai42 merged 8 commits into
mainfrom
kaiyi/vienna-v1

Conversation

@RealKai42

@RealKai42 RealKai42 commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

Problem

Skills discovered under a project directory (e.g. <project>/.kimi/skills or <project>/.claude/skills) were effectively invisible to the model's reasoning about "the skill in this project":

  1. The system prompt listed all skills in a single flat bullet list with no scope labels. The model could only guess which scope each skill came from by parsing path prefixes (/Users/x/.claude/... vs /Users/x/git/proj/.claude/...), which made it easy to conflate project and user scopes across turns.
  2. When a skill name existed in several scopes, the discovery order was builtin → user → project and the resolver used "first wins", so built-in and user-level skills silently shadowed project-level ones — the opposite of what users expect and the opposite of what every comparable tool does.
  3. There was no way to add a custom skills directory without clobbering auto-discovery (the existing --skills-dir flag is an override, not an addition).

Together these meant that a user placing a skill at <project>/.claude/skills/foo/SKILL.md could not get the model to treat it as the authoritative foo, even after multiple explicit corrections.

Changes

  • Scope-group the skills block. Skill now carries a required scope (builtin / user / project / extra). The system prompt renders skills under ### Project / ### User / ### Extra / ### Built-in headings (empty groups omitted). The system.md block now explicitly teaches the model the precedence rule.
  • Reverse the priority. resolve_skills_roots now appends project → user → extra(config) → extra(plugins) → builtin, so the existing "first wins" dedup in discover_skills_from_roots translates to Project overrides User overrides Extra overrides Built-in. This matches the documented behaviour and the system-prompt wording.
  • Accept flat .md skills. <skills_dir>/<name>.md files are now recognised as skills (name = filename stem unless frontmatter sets name:). Subdirectory layout (<name>/SKILL.md) still wins on name conflict inside the same directory. Description resolution follows a unified three-step chain across both forms: frontmatter description: → first non-empty body line (capped at 240 characters) → "No description provided.".
  • Add extra_skill_dirs config. A new extra_skill_dirs: list[str] field adds directories on top of auto-discovery (additive, not override). Entries accept absolute paths, ~-prefixed paths (expanded against $HOME), and paths relative to the project root (the nearest .git ancestor of the work directory, not the CWD). Non-existent entries are silently skipped; symlink and trailing-slash duplicates canonicalize to one.
  • Harden discovery. OSError from is_dir / iterdir (e.g. a permission-denied extra dir) is now logged and skipped instead of aborting the whole discovery pass.

Internal API changes (not breaking for external users)

No external callers; src/ and tests/ are fully migrated in this PR.

  • Skill.scope: SkillScope is required (no default).
  • Skill.skill_md_file: KaosPath is a required data field (was a computed @property).
  • resolve_skills_roots(...) and discover_skills_from_roots(...) now return / accept list[ScopedSkillsRoot] (was list[KaosPath]). The old flat-list variants were removed since there were zero production callers.

Testing

  • Full suite: 2580 passed, 8 skipped, 1 xfailed (0 regressions).
  • ruff check / ruff format --check / pyright: all clean.
  • 20+ new tests, including:
    • End-to-end smoke: test_end_to_end_project_override_renders_correctly — 4 scopes define foo; the rendered system prompt shows foo exactly once, under ### Project, with the project path/description, and no "builtin/user/extra version" string leaks through.
    • Smoking-gun priority: 5 pairwise + 1 three-way same-name-conflict tests covering Project > User > Extra > Built-in transitively.
    • Flat .md form: basic discovery, name-from-stem, frontmatter-name-wins-over-stem, frontmatter-description-wins-over-body, body fallback + 240-char truncation, subdir-shadows-flat-on-conflict, bare SKILL.md ignored.
    • extra_skill_dirs: append semantics, ~ expansion, project-root relative resolution (not CWD), absolute paths, missing-path silent skip, symlink dedup, trailing-slash dedup, auto-discovery overlap dedup, CLI override + extra coexistence, CLI wins over extra on name conflict.
    • Contract: Skill(...) without scope raises pydantic.ValidationError.
    • Resilience: iterdir raising PermissionError returns [] instead of crashing.

Files changed

17 files: 3 production modules (src/kimi_cli/skill/__init__.py, src/kimi_cli/soul/agent.py, src/kimi_cli/utils/{path,frontmatter}.py), 1 config schema, 1 default system prompt, 2 docs (en/zh), 2 changelogs (source + en), 1 zh changelog, 5 test files (1 new). ≈ +1534 / -136.


Open in Devin Review

…errides

Group discovered skills under Project / User / Extra / Built-in headings
in the system prompt and reverse the resolution priority so project-scope
skills correctly override user- and bundled ones on name conflict.

Also accept flat <name>.md skills, add extra_skill_dirs config, and
harden OSError handling in discovery.
Copilot AI review requested due to automatic review settings April 23, 2026 17:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the skills system so the model can clearly distinguish skill origin scopes in the system prompt and so project-local skills properly override user/built-in skills during discovery/resolution.

Changes:

  • Introduces explicit skill scopes (project/user/extra/builtin) and renders the system prompt skills block grouped under scope headings with documented precedence.
  • Reorders layered discovery so precedence becomes Project > User > Extra > Built-in, and adds additive extra_skill_dirs configuration support.
  • Extends discovery to support flat <name>.md skills, unifying description fallback logic and hardening discovery against OSError.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/kimi_cli/skill/__init__.py Adds scope-aware root resolution, scoped skill discovery, flat .md support, and grouped prompt formatting.
src/kimi_cli/soul/agent.py Switches agent system-prompt skill rendering to the new grouped formatter and passes extra_skill_dirs.
src/kimi_cli/utils/path.py Adds shared find_project_root() used for resolving relative extra skill dirs (and AGENTS discovery).
src/kimi_cli/utils/frontmatter.py Adds strip_frontmatter() helper reused by skill description fallback.
src/kimi_cli/config.py Adds extra_skill_dirs to config schema.
src/kimi_cli/agents/default/system.md Updates system prompt instructions to explain scope grouping and precedence.
tests/core/test_skills_prompt.py New tests validating scope-grouped skills prompt rendering and end-to-end override behavior.
tests/core/test_skill.py Updates/extends discovery tests for scoped roots, precedence, flat .md skills, extra dirs, and resilience.
tests/core/test_plugin_manager.py Updates to new scoped-roots return type from resolve_skills_roots().
tests/core/test_kimisoul_slash_commands.py Updates Skill construction to include required scope and skill_md_file.
tests/core/test_default_agent.py Updates expected default agent system prompt text about skill scope/precedence.
tests/core/test_config.py Updates default config dump snapshot to include extra_skill_dirs.
docs/en/customization/skills.md Documents new precedence, additive extra_skill_dirs, and flat .md skills.
docs/zh/customization/skills.md Chinese documentation updates mirroring the new behavior and config.
CHANGELOG.md Adds Unreleased entries describing the skill discovery/prompt changes.
docs/en/release-notes/changelog.md Syncs Unreleased changelog entries into the docs changelog page.
docs/zh/release-notes/changelog.md Adds translated Unreleased changelog entries for the same feature set.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/kimi_cli/skill/__init__.py Outdated
Comment on lines +609 to +616
"""
from kimi_cli.utils.frontmatter import strip_frontmatter

body = strip_frontmatter(content)
for line in body.splitlines():
stripped = line.strip()
if stripped:
return stripped

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_first_meaningful_line() can return '---' as the description fallback when a skill file starts with a malformed YAML frontmatter opener (--- without a closing ---): parse_frontmatter() returns None, strip_frontmatter() returns the text unchanged, and the first non-empty line is the marker. Consider skipping standalone --- marker lines when deriving the body-first-line description (or change strip_frontmatter() to drop the opener even for malformed blocks).

Suggested change
"""
from kimi_cli.utils.frontmatter import strip_frontmatter
body = strip_frontmatter(content)
for line in body.splitlines():
stripped = line.strip()
if stripped:
return stripped
Standalone frontmatter delimiter lines are ignored so malformed YAML
openers do not become fallback descriptions.
"""
from kimi_cli.utils.frontmatter import strip_frontmatter
body = strip_frontmatter(content)
for line in body.splitlines():
stripped = line.strip()
if not stripped or stripped == "---":
continue
return stripped

Copilot uses AI. Check for mistakes.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 6 additional findings.

Open in Devin Review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71bbef43f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/kimi_cli/skill/__init__.py Outdated
Comment on lines +215 to +216
canon = root.canonical()
except OSError:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve symlinked skill roots before deduplicating

The dedup key in _append is built from root.canonical(), but KaosPath.canonical() only normalizes path segments and does not resolve symlinks, so two entries that point to the same directory via a real path and a symlink are still treated as distinct roots. In that scenario, the same skill directory is scanned multiple times and the new "symlink dedup" behavior documented in this change is not actually enforced. Use a backend-aware realpath/symlink-resolution step for the dedup key instead of plain canonical().

Useful? React with 👍 / 👎.

Comment on lines +281 to +282
p = Path(raw).expanduser()
except (RuntimeError, OSError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expand extra_skill_dirs with backend-aware home semantics

_resolve_extra_skill_dir expands ~ using pathlib.Path.expanduser(), which uses the local process home, while the rest of skill discovery is KAOS-path based. On non-local backends (for example, SSH where remote $HOME differs), a config entry like ~/skills resolves to the wrong location and is skipped, so extra_skill_dirs silently fails for that setup. This should use KAOS path expansion (e.g., KaosPath(...).expanduser()) before resolving relative paths.

Useful? React with 👍 / 👎.

Signed-off-by: Kai <me@kaiyi.cool>
…-scope skills

Previously find_project_skills_dirs looked only under the work directory,
which meant that running kimi-cli from a subdirectory (e.g. inside a
monorepo package) missed project-scope skills defined at the repository
root. Walk up to the nearest .git ancestor first; fall back to the work
directory itself when no .git marker is found.
Users who keep skills in multiple brand directories (for example both
.kimi/skills and .claude/skills) previously lost visibility of
everything outside the first existing brand directory, because the
default was to "use only the first one found". Flip the default so all
existing brand directories are merged. Users who want the old
first-match behavior can opt back in via config.

This is a behavior change; see CHANGELOG.md.
The `mis-group` idiom in a defence-in-depth comment gets split into two
tokens by the typos spellchecker, flagging the bare "mis" as a typo for
"miss". Reword to keep the intent without tripping CI.
…allback

When a skill file starts with a malformed YAML frontmatter opener ("---"
without a closing "---"), parse_frontmatter returns None and
strip_frontmatter leaves the content unchanged, so the first meaningful
line used for the description fallback was the opener itself. Skip
standalone "---" delimiter lines in _first_meaningful_line so malformed
openers do not become fallback descriptions.
KaosPath.canonical() only normalizes path segments and explicitly does
not resolve symlinks, so an extra_skill_dirs entry pointing at the real
directory and another pointing at a symlink of the same directory were
treated as distinct roots. The PR's advertised "symlink dedup" was not
actually enforced, and the test meant to lock the behavior only asserted
how many roots match real.resolve(), not how many roots total — so the
symlinked root was silently accepted.

On local backends, walk symlinks via pathlib.Path.resolve() before
building the dedup key and storing the canonicalized root. Non-local
backends (where resolve() would walk the wrong filesystem) keep the
canonical-only behavior. Also tighten the dedup test to assert
len(roots) == 1 and that the surviving root is the real target.
Signed-off-by: Kai <me@kaiyi.cool>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6079c8696

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +228 to +230
try:
local_resolved = Path(str(root)).resolve()
except OSError:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle symlink-loop errors when canonicalizing skill roots

_append resolves local roots via Path(str(root)).resolve(), but pathlib raises RuntimeError (not OSError) for symlink loops. In that case—e.g., a user passes --skills-dir that points into a cyclic symlink—the exception escapes this block and aborts startup instead of skipping the bad entry like other discovery errors. Catching RuntimeError here (or broadening the guard) keeps one malformed path from taking down skill discovery.

Useful? React with 👍 / 👎.

@RealKai42
RealKai42 merged commit 4335ee5 into main Apr 24, 2026
15 checks passed
@RealKai42
RealKai42 deleted the kaiyi/vienna-v1 branch April 24, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants