Vectrum
Sign In
250 lines · 9.5 KB

Skills from .agents/skills

Give the harness the skill mechanic Claude Code and OpenCode have: a directory of Markdown instruction files the agent can pull in when one is relevant, and the user can force with a slash command.

Two ways in, one way to load. The agent calls a skill tool; the user types /slug, which becomes a user turn the model answers by calling that same tool. Nothing loads a skill body except the tool, so both paths produce identical transcripts and -p "/slug ..." works without a second implementation.

Format

A skill is a directory containing SKILL.md:

~/.agents/skills/commit-helper/SKILL.md
                              /references/conventions.md
                              /scripts/changelog.sh

SKILL.md is YAML frontmatter followed by the instructions:

---
name: commit-helper
description: Use when writing commit messages for this repo
---

Read the last twenty commits for tone before writing anything.
Conventions are in references/conventions.md.

name and description are required. description is the only thing the model sees until the skill is invoked, so it carries the whole triggering decision; the body can be any length. Unknown frontmatter keys are ignored rather than rejected, so a skill written for another harness still loads here.

The directory name is the slug and the authority. A name that disagrees with the directory is a load error naming both, because the alternative is a skill that answers to a name nobody can see in the tree.

Slugs match ^[a-z0-9][a-z0-9-]*$. Anything else is skipped: the slug becomes a slash command and a JSON enum value, and neither wants spaces in it.

Discovery

Two roots, scanned in order, project shadowing personal on a slug collision:

RootScope
~/.agents/skillsevery repo this user opens
<workroot>/.agents/skillsthis repo, and checked in with it

One level deep only. ~/.agents/skills/foo/bar/SKILL.md is not a skill, it is a file inside skill foo.

Discovery runs once at startup. A skill added while the harness is running is not picked up, because the listing sits in the cached prompt prefix and rebuilding it mid-session would cost a cache miss on every subsequent turn for a file that changes approximately never.

A malformed skill is skipped with a notice in the transcript naming the path and the reason. A missing root directory is not an error at all — neither root exists on a fresh machine, and that is the normal case. A root that exists but cannot be read is reported the same way a malformed skill is. Nothing about skills aborts startup; a harness that will not open because of a stray file in ~ is worse than one that runs without the skill it was going to load.

Loading: the skill tool

internal/tools/skill.go registers one tool:

skill(name: string, args?: string) -> string

name is a JSON enum of the discovered slugs, so a hallucinated skill name is a schema violation the model sees immediately instead of a lookup failure it has to reason about.

It returns the body with a preamble naming the skill's directory:

Skill: commit-helper
Directory: /home/joni/.agents/skills/commit-helper
Relative paths below resolve against that directory.

<body>

args, when present, is appended under an ARGUMENTS: line. The tool is read-only and runs unattended like read_file — it reads one file from a directory discovered at startup, and cannot reach anything the read tools could not already reach once the jail change below lands.

Invoking the same skill twice in a session returns the body twice. Suppressing the repeat would save tokens and cost the model the ability to re-read instructions after compaction has dropped them.

Telling the model skills exist

agent.Options grows Skills []skills.Skill. When non-empty, request() appends to the system prompt:

Skills are instruction sets for specific kinds of work. Invoke one with the
skill tool when its description matches what you are doing — before starting,
not after. A user message beginning with /<name> is a request to invoke that
skill; everything after the name is its arguments.

Available skills:
  commit-helper: Use when writing commit messages for this repo
  ...

This is appended, not interpolated, and is constant for the session, so the cached prefix still matches request to request. The reasoning at internal/agent/loop.go:22 holds: what breaks caching is content that varies per request, not content that varies per session.

With no skills discovered, nothing is appended and no tool is registered. A user with no .agents/skills anywhere sees a harness identical to today's, down to the token count.

User invocation

internal/tui/model.go gains two cases ahead of the unknown-command error at line 448:

  • /skills lists slug, description and source root, or says none were found.
  • /<slug>, where the slug is one of the discovered skills, sends the line verbatim as a user turn.

An unrecognised slash command still errors exactly as it does now. The check is against the discovered set, so a typo is caught by the harness rather than sent to the model to puzzle over.

/help gains /skills in its command list.

The line-based front end needs no change. /slug reaches the model as text, the system prompt explains what a leading slash means, and the tool does the rest — so -p "/commit-helper fix the flaky test" works for free.

Path jail

jail() at internal/tools/read.go:141 takes one root. Skill bodies reference their own bundled files, and for a personal skill those live under ~, outside the working root, where the jail correctly refuses them today.

jail grows a set of extra permitted roots. A path is allowed if it resolves under the working root or under any extra root, with the same symlink resolution applied to each so the extra roots cannot be used to step sideways.

Which tools get them:

ToolExtra rootsWhy
read_fileskill dirsread references/conventions.md
globskill dirsfind what a skill ships
grepskill dirssearch it
edit_filenonea skill is input, not a work product
write_filenoneas above
bashnoneunchanged; cwd stays the working root

Skills are readable and never writable. The agent cannot rewrite its own instructions through a tool, which is worth more than the convenience of letting it.

The extra roots are the individual skill directories, not the two parent roots. A skill can read its own bundle and its siblings' bundles; it cannot read arbitrary files under ~ that happen to sit next to the skills directory.

resolveBase is unchanged. Only the jail check learns about extra roots.

Flags

-no-skills skips discovery entirely: no tool, no system prompt addition, no extra jail roots. The eval runner sets it, so a task's token counts do not move because of what happens to be in the developer's home directory. METRICS.md compares runs, and that only means something if the prompt prefix is the same between them.

Dependency

gopkg.in/yaml.v3 for the frontmatter. Descriptions are prose and routinely use folded (>-) and quoted scalars; a two-key line parser mis-reads those and fails by silently not loading the skill, which is the worst available failure mode for this feature.

Package shape

internal/skills   discovery and parsing; no knowledge of tools, agent or tui
type Skill struct {
    Name        string // slug, from the directory
    Description string
    Body        string // SKILL.md after the frontmatter
    Dir         string // absolute
    Source      string // "personal" | "project"
}

// Discover scans the personal and project roots. Malformed skills are
// returned as problems rather than errors; only an unreadable root is an error.
func Discover(home, workroot string) (found []Skill, problems []error, err error)

home and workroot are parameters rather than looked up inside, so tests point them at t.TempDir().

Testing

internal/skills:

  • a well-formed skill loads: name, description, body, dir, source
  • project shadows personal on the same slug
  • missing frontmatter, missing description, name disagreeing with the directory, and a bad slug are each reported as problems, and the other skills in the same root still load
  • a folded and a quoted description parse to the same text as a plain one
  • neither root existing yields no skills, no problems, no error
  • a/b/SKILL.md two levels down is not discovered

internal/tools:

  • skill returns the preamble, directory and body; args appends ARGUMENTS:
  • the schema enumerates exactly the discovered slugs
  • read_file reads a file under a skill dir outside the working root
  • write_file and edit_file still refuse that same path
  • a path outside both the working root and every extra root is still refused, including through a symlink from inside a skill dir

internal/agent:

  • the listing is appended to the system prompt when skills are present, and the prompt is byte-identical to today's when they are not
  • the system prompt is identical across two consecutive requests in a session

internal/tui:

  • /skills lists them; /skills with none found says so
  • /commit-helper args sends a user turn rather than erroring
  • /bogus still errors
  • /help mentions /skills

Out of scope

Tab completion for slash commands, allowed-tools restrictions per skill, skills invoking other skills, remote or plugin skill sources, and hot reload. Each is a separate change on top of this one, and none is needed to match what Claude Code does with a directory of Markdown files.