Agent-invoked via a skill tool, user-invoked via /slug, one loading path for both. Extends the path jail with read-only extra roots so a skill can read its own bundled files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 1 changed file with 250 additions and 0 deletions.
| @@ -0,0 +1,250 @@ | ||
| 1 | + # Skills from `.agents/skills` | |
| 2 | + | |
| 3 | + Give the harness the skill mechanic Claude Code and OpenCode have: a directory | |
| 4 | + of Markdown instruction files the agent can pull in when one is relevant, and | |
| 5 | + the user can force with a slash command. | |
| 6 | + | |
| 7 | + Two ways in, one way to load. The agent calls a `skill` tool; the user types | |
| 8 | + `/slug`, which becomes a user turn the model answers by calling that same tool. | |
| 9 | + Nothing loads a skill body except the tool, so both paths produce identical | |
| 10 | + transcripts and `-p "/slug ..."` works without a second implementation. | |
| 11 | + | |
| 12 | + ## Format | |
| 13 | + | |
| 14 | + A skill is a directory containing `SKILL.md`: | |
| 15 | + | |
| 16 | + ``` | |
| 17 | + ~/.agents/skills/commit-helper/SKILL.md | |
| 18 | + /references/conventions.md | |
| 19 | + /scripts/changelog.sh | |
| 20 | + ``` | |
| 21 | + | |
| 22 | + `SKILL.md` is YAML frontmatter followed by the instructions: | |
| 23 | + | |
| 24 | + ```markdown | |
| 25 | + --- | |
| 26 | + name: commit-helper | |
| 27 | + description: Use when writing commit messages for this repo | |
| 28 | + --- | |
| 29 | + | |
| 30 | + Read the last twenty commits for tone before writing anything. | |
| 31 | + Conventions are in references/conventions.md. | |
| 32 | + ``` | |
| 33 | + | |
| 34 | + `name` and `description` are required. `description` is the only thing the | |
| 35 | + model sees until the skill is invoked, so it carries the whole triggering | |
| 36 | + decision; the body can be any length. Unknown frontmatter keys are ignored | |
| 37 | + rather than rejected, so a skill written for another harness still loads here. | |
| 38 | + | |
| 39 | + The directory name is the slug and the authority. A `name` that disagrees with | |
| 40 | + the directory is a load error naming both, because the alternative is a skill | |
| 41 | + that answers to a name nobody can see in the tree. | |
| 42 | + | |
| 43 | + Slugs match `^[a-z0-9][a-z0-9-]*$`. Anything else is skipped: the slug becomes | |
| 44 | + a slash command and a JSON enum value, and neither wants spaces in it. | |
| 45 | + | |
| 46 | + ## Discovery | |
| 47 | + | |
| 48 | + Two roots, scanned in order, project shadowing personal on a slug collision: | |
| 49 | + | |
| 50 | + | Root | Scope | | |
| 51 | + | --- | --- | | |
| 52 | + | `~/.agents/skills` | every repo this user opens | | |
| 53 | + | `<workroot>/.agents/skills` | this repo, and checked in with it | | |
| 54 | + | |
| 55 | + One level deep only. `~/.agents/skills/foo/bar/SKILL.md` is not a skill, it is | |
| 56 | + a file inside skill `foo`. | |
| 57 | + | |
| 58 | + Discovery runs once at startup. A skill added while the harness is running is | |
| 59 | + not picked up, because the listing sits in the cached prompt prefix and | |
| 60 | + rebuilding it mid-session would cost a cache miss on every subsequent turn for | |
| 61 | + a file that changes approximately never. | |
| 62 | + | |
| 63 | + A malformed skill is skipped with a notice in the transcript naming the path | |
| 64 | + and the reason. A missing root directory is not an error at all — neither root | |
| 65 | + exists on a fresh machine, and that is the normal case. A root that exists but | |
| 66 | + cannot be read is reported the same way a malformed skill is. Nothing about | |
| 67 | + skills aborts startup; a harness that will not open because of a stray file in | |
| 68 | + `~` is worse than one that runs without the skill it was going to load. | |
| 69 | + | |
| 70 | + ## Loading: the `skill` tool | |
| 71 | + | |
| 72 | + `internal/tools/skill.go` registers one tool: | |
| 73 | + | |
| 74 | + ``` | |
| 75 | + skill(name: string, args?: string) -> string | |
| 76 | + ``` | |
| 77 | + | |
| 78 | + `name` is a JSON enum of the discovered slugs, so a hallucinated skill name is | |
| 79 | + a schema violation the model sees immediately instead of a lookup failure it | |
| 80 | + has to reason about. | |
| 81 | + | |
| 82 | + It returns the body with a preamble naming the skill's directory: | |
| 83 | + | |
| 84 | + ``` | |
| 85 | + Skill: commit-helper | |
| 86 | + Directory: /home/joni/.agents/skills/commit-helper | |
| 87 | + Relative paths below resolve against that directory. | |
| 88 | + | |
| 89 | + <body> | |
| 90 | + ``` | |
| 91 | + | |
| 92 | + `args`, when present, is appended under an `ARGUMENTS:` line. The tool is | |
| 93 | + read-only and runs unattended like `read_file` — it reads one file from a | |
| 94 | + directory discovered at startup, and cannot reach anything the read tools | |
| 95 | + could not already reach once the jail change below lands. | |
| 96 | + | |
| 97 | + Invoking the same skill twice in a session returns the body twice. Suppressing | |
| 98 | + the repeat would save tokens and cost the model the ability to re-read | |
| 99 | + instructions after compaction has dropped them. | |
| 100 | + | |
| 101 | + ## Telling the model skills exist | |
| 102 | + | |
| 103 | + `agent.Options` grows `Skills []skills.Skill`. When non-empty, `request()` | |
| 104 | + appends to the system prompt: | |
| 105 | + | |
| 106 | + ``` | |
| 107 | + Skills are instruction sets for specific kinds of work. Invoke one with the | |
| 108 | + skill tool when its description matches what you are doing — before starting, | |
| 109 | + not after. A user message beginning with /<name> is a request to invoke that | |
| 110 | + skill; everything after the name is its arguments. | |
| 111 | + | |
| 112 | + Available skills: | |
| 113 | + commit-helper: Use when writing commit messages for this repo | |
| 114 | + ... | |
| 115 | + ``` | |
| 116 | + | |
| 117 | + This is appended, not interpolated, and is constant for the session, so the | |
| 118 | + cached prefix still matches request to request. The reasoning at | |
| 119 | + `internal/agent/loop.go:22` holds: what breaks caching is content that varies | |
| 120 | + per request, not content that varies per session. | |
| 121 | + | |
| 122 | + With no skills discovered, nothing is appended and no tool is registered. A | |
| 123 | + user with no `.agents/skills` anywhere sees a harness identical to today's, | |
| 124 | + down to the token count. | |
| 125 | + | |
| 126 | + ## User invocation | |
| 127 | + | |
| 128 | + `internal/tui/model.go` gains two cases ahead of the unknown-command error at | |
| 129 | + line 448: | |
| 130 | + | |
| 131 | + - `/skills` lists slug, description and source root, or says none were found. | |
| 132 | + - `/<slug>`, where the slug is one of the discovered skills, sends the line | |
| 133 | + verbatim as a user turn. | |
| 134 | + | |
| 135 | + An unrecognised slash command still errors exactly as it does now. The check is | |
| 136 | + against the discovered set, so a typo is caught by the harness rather than sent | |
| 137 | + to the model to puzzle over. | |
| 138 | + | |
| 139 | + `/help` gains `/skills` in its command list. | |
| 140 | + | |
| 141 | + The line-based front end needs no change. `/slug` reaches the model as text, | |
| 142 | + the system prompt explains what a leading slash means, and the tool does the | |
| 143 | + rest — so `-p "/commit-helper fix the flaky test"` works for free. | |
| 144 | + | |
| 145 | + ## Path jail | |
| 146 | + | |
| 147 | + `jail()` at `internal/tools/read.go:141` takes one root. Skill bodies reference | |
| 148 | + their own bundled files, and for a personal skill those live under `~`, outside | |
| 149 | + the working root, where the jail correctly refuses them today. | |
| 150 | + | |
| 151 | + `jail` grows a set of extra permitted roots. A path is allowed if it resolves | |
| 152 | + under the working root **or** under any extra root, with the same symlink | |
| 153 | + resolution applied to each so the extra roots cannot be used to step sideways. | |
| 154 | + | |
| 155 | + Which tools get them: | |
| 156 | + | |
| 157 | + | Tool | Extra roots | Why | | |
| 158 | + | --- | --- | --- | | |
| 159 | + | `read_file` | skill dirs | read `references/conventions.md` | | |
| 160 | + | `glob` | skill dirs | find what a skill ships | | |
| 161 | + | `grep` | skill dirs | search it | | |
| 162 | + | `edit_file` | none | a skill is input, not a work product | | |
| 163 | + | `write_file` | none | as above | | |
| 164 | + | `bash` | none | unchanged; `cwd` stays the working root | | |
| 165 | + | |
| 166 | + Skills are readable and never writable. The agent cannot rewrite its own | |
| 167 | + instructions through a tool, which is worth more than the convenience of | |
| 168 | + letting it. | |
| 169 | + | |
| 170 | + The extra roots are the individual skill directories, not the two parent roots. | |
| 171 | + A skill can read its own bundle and its siblings' bundles; it cannot read | |
| 172 | + arbitrary files under `~` that happen to sit next to the skills directory. | |
| 173 | + | |
| 174 | + `resolveBase` is unchanged. Only the jail check learns about extra roots. | |
| 175 | + | |
| 176 | + ## Flags | |
| 177 | + | |
| 178 | + `-no-skills` skips discovery entirely: no tool, no system prompt addition, no | |
| 179 | + extra jail roots. The eval runner sets it, so a task's token counts do not move | |
| 180 | + because of what happens to be in the developer's home directory. METRICS.md | |
| 181 | + compares runs, and that only means something if the prompt prefix is the same | |
| 182 | + between them. | |
| 183 | + | |
| 184 | + ## Dependency | |
| 185 | + | |
| 186 | + `gopkg.in/yaml.v3` for the frontmatter. Descriptions are prose and routinely | |
| 187 | + use folded (`>-`) and quoted scalars; a two-key line parser mis-reads those and | |
| 188 | + fails by silently not loading the skill, which is the worst available failure | |
| 189 | + mode for this feature. | |
| 190 | + | |
| 191 | + ## Package shape | |
| 192 | + | |
| 193 | + ``` | |
| 194 | + internal/skills discovery and parsing; no knowledge of tools, agent or tui | |
| 195 | + ``` | |
| 196 | + | |
| 197 | + ```go | |
| 198 | + type Skill struct { | |
| 199 | + Name string // slug, from the directory | |
| 200 | + Description string | |
| 201 | + Body string // SKILL.md after the frontmatter | |
| 202 | + Dir string // absolute | |
| 203 | + Source string // "personal" | "project" | |
| 204 | + } | |
| 205 | + | |
| 206 | + // Discover scans the personal and project roots. Malformed skills are | |
| 207 | + // returned as problems rather than errors; only an unreadable root is an error. | |
| 208 | + func Discover(home, workroot string) (found []Skill, problems []error, err error) | |
| 209 | + ``` | |
| 210 | + | |
| 211 | + `home` and `workroot` are parameters rather than looked up inside, so tests | |
| 212 | + point them at `t.TempDir()`. | |
| 213 | + | |
| 214 | + ## Testing | |
| 215 | + | |
| 216 | + `internal/skills`: | |
| 217 | + - a well-formed skill loads: name, description, body, dir, source | |
| 218 | + - project shadows personal on the same slug | |
| 219 | + - missing frontmatter, missing `description`, `name` disagreeing with the | |
| 220 | + directory, and a bad slug are each reported as problems, and the other skills | |
| 221 | + in the same root still load | |
| 222 | + - a folded and a quoted description parse to the same text as a plain one | |
| 223 | + - neither root existing yields no skills, no problems, no error | |
| 224 | + - `a/b/SKILL.md` two levels down is not discovered | |
| 225 | + | |
| 226 | + `internal/tools`: | |
| 227 | + - `skill` returns the preamble, directory and body; `args` appends `ARGUMENTS:` | |
| 228 | + - the schema enumerates exactly the discovered slugs | |
| 229 | + - `read_file` reads a file under a skill dir outside the working root | |
| 230 | + - `write_file` and `edit_file` still refuse that same path | |
| 231 | + - a path outside both the working root and every extra root is still refused, | |
| 232 | + including through a symlink from inside a skill dir | |
| 233 | + | |
| 234 | + `internal/agent`: | |
| 235 | + - the listing is appended to the system prompt when skills are present, and the | |
| 236 | + prompt is byte-identical to today's when they are not | |
| 237 | + - the system prompt is identical across two consecutive requests in a session | |
| 238 | + | |
| 239 | + `internal/tui`: | |
| 240 | + - `/skills` lists them; `/skills` with none found says so | |
| 241 | + - `/commit-helper args` sends a user turn rather than erroring | |
| 242 | + - `/bogus` still errors | |
| 243 | + - `/help` mentions `/skills` | |
| 244 | + | |
| 245 | + ## Out of scope | |
| 246 | + | |
| 247 | + Tab completion for slash commands, `allowed-tools` restrictions per skill, | |
| 248 | + skills invoking other skills, remote or plugin skill sources, and hot reload. | |
| 249 | + Each is a separate change on top of this one, and none is needed to match what | |
| 250 | + Claude Code does with a directory of Markdown files. | |