Vectrum
Sign In

Design: skills from ~/.agents/skills and .agents/skills

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>
Jjonicommitted 1 parent 75015bacommitd5ef6af

Showing 1 changed file with 250 additions and 0 deletions.

+250−0docs/superpowers/specs/2026-09-17-agents-skills-design.mdaddedView File
@@ -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.