For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let the agent invoke instruction sets from ~/.agents/skills and <repo>/.agents/skills on its own, and let the user force one with /slug.
Architecture: A new internal/skills package discovers <root>/<slug>/SKILL.md files and parses their YAML frontmatter. A new skill tool returns a body on request; it is the only thing that loads a body, so agent-invoked and user-invoked paths are the same code. The slug/description listing is appended to the system prompt once at agent construction, keeping the cached prefix constant for the session. The path jail grows read-only extra roots so a skill can read its own bundled files.
Tech Stack: Go 1.26, gopkg.in/yaml.v3 (new), invopop/jsonschema (existing, for the tool schema), Bubble Tea (existing, for the TUI).
Spec: docs/superpowers/specs/2026-09-17-agents-skills-design.md
harness; import paths are harness/internal/....internal/agent must never import internal/ui or internal/tui. It may import internal/skills.(string, error) where the error is text the model reads.^[a-z0-9][a-z0-9-]*$.name must equal the directory name, or the skill is a problem.edit_file, write_file and bash get no extra roots.gofmt -w on every file you touch before committing.internal/skills packageFiles:
internal/skills/skills.gointernal/skills/skills_test.gogo.mod, go.sumInterfaces:
Consumes: nothing.
Produces: skills.Skill{Name, Description, Body, Dir, Source string} and skills.Discover(home, workroot string) ([]Skill, []error). Later tasks depend on both. Source is the literal string "personal" or "project". The returned slice is sorted by Name.
Step 1: Add the YAML dependency
cd /home/joni/Work/GO/coding-harness
go get gopkg.in/yaml.v3@v3.0.1
Create internal/skills/skills_test.go:
package skills
import (
"os"
"path/filepath"
"strings"
"testing"
)
// write creates <root>/<slug>/SKILL.md with the given contents.
func write(t *testing.T, root, slug, body string) string {
t.Helper()
dir := filepath.Join(root, slug)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
const wellFormed = `---
name: commit-helper
description: Use when writing commit messages
---
Read the last twenty commits for tone.
`
func TestDiscoverWellFormed(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
dir := write(t, filepath.Join(home, ".agents", "skills"), "commit-helper", wellFormed)
found, problems := Discover(home, work)
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
if len(found) != 1 {
t.Fatalf("got %d skills, want 1", len(found))
}
s := found[0]
if s.Name != "commit-helper" {
t.Errorf("Name = %q", s.Name)
}
if s.Description != "Use when writing commit messages" {
t.Errorf("Description = %q", s.Description)
}
if s.Source != "personal" {
t.Errorf("Source = %q, want personal", s.Source)
}
if s.Dir != dir {
t.Errorf("Dir = %q, want %q", s.Dir, dir)
}
if !strings.Contains(s.Body, "last twenty commits") {
t.Errorf("Body = %q", s.Body)
}
if strings.Contains(s.Body, "description:") {
t.Error("Body still contains the frontmatter")
}
}
func TestProjectShadowsPersonal(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
write(t, filepath.Join(home, ".agents", "skills"), "dup", `---
name: dup
description: personal one
---
personal body
`)
write(t, filepath.Join(work, ".agents", "skills"), "dup", `---
name: dup
description: project one
---
project body
`)
found, problems := Discover(home, work)
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
if len(found) != 1 {
t.Fatalf("got %d skills, want 1", len(found))
}
if found[0].Description != "project one" || found[0].Source != "project" {
t.Errorf("personal skill was not shadowed: %+v", found[0])
}
}
func TestMalformedSkillsAreProblemsAndDoNotBlockOthers(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
root := filepath.Join(home, ".agents", "skills")
write(t, root, "good", wellFormed2())
write(t, root, "no-frontmatter", "just a body, no frontmatter\n")
write(t, root, "no-description", "---\nname: no-description\n---\nbody\n")
write(t, root, "wrong-name", "---\nname: something-else\ndescription: d\n---\nbody\n")
write(t, root, "Bad_Slug", "---\nname: Bad_Slug\ndescription: d\n---\nbody\n")
found, problems := Discover(home, work)
if len(found) != 1 || found[0].Name != "good" {
t.Fatalf("got %+v, want only the good skill", found)
}
if len(problems) != 4 {
t.Fatalf("got %d problems, want 4: %v", len(problems), problems)
}
// Every problem names the offending path, or it is not actionable.
for _, p := range problems {
if !strings.Contains(p.Error(), root) {
t.Errorf("problem does not name a path: %v", p)
}
}
}
func wellFormed2() string {
return "---\nname: good\ndescription: a good skill\n---\nbody\n"
}
func TestDescriptionScalarStyles(t *testing.T) {
// A folded and a quoted description must parse to the same text as a plain
// one. This is the whole reason for the yaml dependency.
cases := map[string]string{
"plain": "description: Use when writing commit messages",
"quoted": `description: "Use when writing commit messages"`,
"folded": "description: >-\n Use when writing\n commit messages",
}
for slug, line := range cases {
t.Run(slug, func(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
write(t, filepath.Join(home, ".agents", "skills"), slug,
"---\nname: "+slug+"\n"+line+"\n---\nbody\n")
found, problems := Discover(home, work)
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
if len(found) != 1 || found[0].Description != "Use when writing commit messages" {
t.Fatalf("Description = %+v", found)
}
})
}
}
func TestNoRootsIsSilent(t *testing.T) {
found, problems := Discover(t.TempDir(), t.TempDir())
if len(found) != 0 || len(problems) != 0 {
t.Fatalf("found %v, problems %v; want both empty", found, problems)
}
}
func TestNestedSkillIsNotDiscovered(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
write(t, filepath.Join(home, ".agents", "skills", "outer"), "inner", wellFormed2())
found, problems := Discover(home, work)
if len(found) != 0 {
t.Fatalf("found %+v, want none", found)
}
// "outer" has no SKILL.md of its own; that is not a problem, just not a skill.
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
}
func TestSortedByName(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
root := filepath.Join(home, ".agents", "skills")
for _, slug := range []string{"zebra", "alpha", "middle"} {
write(t, root, slug, "---\nname: "+slug+"\ndescription: d\n---\nbody\n")
}
found, _ := Discover(home, work)
var got []string
for _, s := range found {
got = append(got, s.Name)
}
if strings.Join(got, ",") != "alpha,middle,zebra" {
t.Errorf("order = %v", got)
}
}
Run: go test ./internal/skills/
Expected: FAIL — undefined: Discover, undefined: Skill.
Create internal/skills/skills.go:
// Package skills discovers instruction sets stored as Markdown on disk.
//
// A skill is a directory containing SKILL.md: YAML frontmatter naming and
// describing it, followed by the instructions. The description is all the
// model sees until the skill is invoked, so it carries the whole triggering
// decision; the body can be any length.
package skills
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// Dirname is the directory, under the home directory and under the working
// root, that skills are read from.
const Dirname = ".agents/skills"
// filename is the one file a skill directory must contain.
const filename = "SKILL.md"
// slugPattern is what a directory name must match to be a skill. The slug
// becomes a slash command and a JSON enum value, and neither wants spaces.
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// Skill is one discovered instruction set.
type Skill struct {
Name string // slug, taken from the directory name
Description string // the one line the model sees before invoking
Body string // SKILL.md with the frontmatter removed
Dir string // absolute path to the skill directory
Source string // "personal" or "project"
}
// frontmatter is the subset of SKILL.md's YAML header this harness reads.
// Unknown keys are ignored rather than rejected, so a skill written for
// another harness still loads here.
type frontmatter struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
}
// Discover scans the personal and project roots, project shadowing personal on
// a slug collision, and returns the skills sorted by name.
//
// Every failure is a problem rather than an error: a harness that will not open
// because of a stray file in the home directory is worse than one that runs
// without the skill it was going to load. A root that does not exist is not
// even a problem — neither exists on a fresh machine, and that is the normal
// case.
func Discover(home, workroot string) ([]Skill, []error) {
byName := map[string]Skill{}
var problems []error
// Later roots win, so the project root is scanned second.
for _, r := range []struct{ dir, source string }{
{filepath.Join(home, Dirname), "personal"},
{filepath.Join(workroot, Dirname), "project"},
} {
found, probs := scan(r.dir, r.source)
problems = append(problems, probs...)
for _, s := range found {
byName[s.Name] = s
}
}
out := make([]Skill, 0, len(byName))
for _, s := range byName {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, problems
}
// scan reads one root, one level deep.
func scan(root, source string) ([]Skill, []error) {
entries, err := os.ReadDir(root)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, []error{fmt.Errorf("%s: %w", root, err)}
}
var found []Skill
var problems []error
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(root, e.Name())
path := filepath.Join(dir, filename)
if _, err := os.Stat(path); err != nil {
// A directory without a SKILL.md is not a skill, and not a
// complaint either: it may be anything at all.
continue
}
s, err := load(dir, e.Name(), source)
if err != nil {
problems = append(problems, err)
continue
}
found = append(found, s)
}
return found, problems
}
// load parses one SKILL.md. slug is the directory name, which is the authority
// for the skill's name.
func load(dir, slug, source string) (Skill, error) {
path := filepath.Join(dir, filename)
if !slugPattern.MatchString(slug) {
return Skill{}, fmt.Errorf("%s: directory name %q is not a valid skill name; use lower-case letters, digits and hyphens", path, slug)
}
raw, err := os.ReadFile(path)
if err != nil {
return Skill{}, fmt.Errorf("%s: %w", path, err)
}
head, body, err := split(string(raw))
if err != nil {
return Skill{}, fmt.Errorf("%s: %w", path, err)
}
var fm frontmatter
if err := yaml.Unmarshal([]byte(head), &fm); err != nil {
return Skill{}, fmt.Errorf("%s: frontmatter is not valid YAML: %w", path, err)
}
switch {
case strings.TrimSpace(fm.Description) == "":
return Skill{}, fmt.Errorf("%s: frontmatter has no description; it is the only thing the model sees before invoking the skill", path)
case strings.TrimSpace(fm.Name) == "":
return Skill{}, fmt.Errorf("%s: frontmatter has no name; it must be %q, matching the directory", path, slug)
case strings.TrimSpace(fm.Name) != slug:
return Skill{}, fmt.Errorf("%s: frontmatter name is %q but the directory is %q; they must agree, and the directory wins", path, fm.Name, slug)
}
abs, err := filepath.Abs(dir)
if err != nil {
abs = dir
}
return Skill{
Name: slug,
Description: strings.TrimSpace(fm.Description),
Body: strings.TrimSpace(body),
Dir: abs,
Source: source,
}, nil
}
// split separates the leading --- fenced YAML block from the body.
func split(s string) (head, body string, err error) {
s = strings.ReplaceAll(s, "\r\n", "\n")
if !strings.HasPrefix(s, "---\n") {
return "", "", fmt.Errorf("does not begin with a --- frontmatter block")
}
rest := s[len("---\n"):]
end := strings.Index(rest, "\n---")
if end < 0 {
return "", "", fmt.Errorf("frontmatter block is never closed with ---")
}
head = rest[:end]
body = rest[end+len("\n---"):]
if i := strings.IndexByte(body, '\n'); i >= 0 {
body = body[i+1:]
} else {
body = ""
}
return head, body, nil
}
Run: go test ./internal/skills/ -v
Expected: PASS, all cases.
cd /home/joni/Work/GO/coding-harness
gofmt -w internal/skills/
go mod tidy
git add internal/skills go.mod go.sum
git commit -m "skills: discover SKILL.md under ~/.agents/skills and .agents/skills"
Files:
internal/tools/read.go (jail at line 141, resolve at line 114, NewRead at line 40)internal/tools/glob.go:45internal/tools/grep.go:74internal/tools/jail_test.goInterfaces:
jailWith(root string, extra []string, path string) (string, error) and variadic constructors NewRead(root string, track ReadTracker, extra ...string), NewGlob(root string, extra ...string), NewGrep(root string, extra ...string). Task 6 passes skill directories to all three.The variadic tail is deliberate: every existing call site — cmd/harness/main.go:104-106, evals/runner.go:192, and the tool tests — keeps compiling unchanged.
Append to internal/tools/jail_test.go:
// A skill lives outside the working root. The read tools reach into it; the
// write tools must not.
func TestExtraRootsAreReadableNotWritable(t *testing.T) {
root := t.TempDir()
skill := t.TempDir()
ref := filepath.Join(skill, "references.md")
if err := os.WriteFile(ref, []byte("conventions\n"), 0o644); err != nil {
t.Fatal(err)
}
read := NewRead(root, nil, skill)
out, err := read.Execute(context.Background(), jsonArgs(t, map[string]any{"path": ref}))
if err != nil {
t.Fatalf("read_file refused an extra root: %v", err)
}
if !strings.Contains(out, "conventions") {
t.Errorf("read_file returned %q", out)
}
write := NewWrite(root, nil)
if _, err := write.Execute(context.Background(), jsonArgs(t, map[string]any{
"path": ref, "content": "rewritten",
})); err == nil {
t.Error("write_file was allowed into a skill directory")
}
}
func TestExtraRootsDoNotWidenTheJail(t *testing.T) {
root := t.TempDir()
skill := t.TempDir()
outside := filepath.Join(t.TempDir(), "secret.txt")
if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil {
t.Fatal(err)
}
read := NewRead(root, nil, skill)
if _, err := read.Execute(context.Background(), jsonArgs(t, map[string]any{"path": outside})); err == nil {
t.Error("a path under neither the root nor an extra root was allowed")
}
}
func TestExtraRootSymlinkCannotStepSideways(t *testing.T) {
root := t.TempDir()
skill := t.TempDir()
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret\n"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(skill, "escape")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
read := NewRead(root, nil, skill)
if _, err := read.Execute(context.Background(), jsonArgs(t, map[string]any{
"path": filepath.Join(link, "secret.txt"),
})); err == nil {
t.Error("a symlink out of a skill directory was followed")
}
}
func TestGlobAndGrepAcceptExtraRoots(t *testing.T) {
root := t.TempDir()
skill := t.TempDir()
if err := os.WriteFile(filepath.Join(skill, "notes.md"), []byte("needle\n"), 0o644); err != nil {
t.Fatal(err)
}
g := NewGlob(root, skill)
if _, err := g.Execute(context.Background(), jsonArgs(t, map[string]any{
"pattern": "*.md", "path": skill,
})); err != nil {
t.Errorf("glob refused an extra root: %v", err)
}
gr := NewGrep(root, skill)
out, err := gr.Execute(context.Background(), jsonArgs(t, map[string]any{
"pattern": "needle", "path": skill,
}))
if err != nil {
t.Errorf("grep refused an extra root: %v", err)
}
if !strings.Contains(out, "needle") {
t.Errorf("grep returned %q", out)
}
}
If jsonArgs does not already exist in the tools test files, add it to jail_test.go:
// jsonArgs marshals a tool's arguments, failing the test rather than the tool.
func jsonArgs(t *testing.T, m map[string]any) json.RawMessage {
t.Helper()
b, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
return b
}
Check first — run grep -rn "func jsonArgs" internal/tools/ and reuse whatever is there, matching its existing signature.
Run: go test ./internal/tools/ -run 'ExtraRoot|GlobAndGrep' -v
Expected: FAIL to compile — too many arguments in call to NewRead.
jail with jailWith in internal/tools/read.goReplace the existing jail function (line 141) with the two below, keeping the original doc comment's intent:
// jail refuses paths outside the working root. Symlinks are resolved first, on
// the existing part of the path, so a link pointing out of the tree cannot be
// used to step around the check. It is cheap and catches a lot; it is not a
// substitute for a sandbox, since bash can still cd anywhere.
func jail(root, path string) (string, error) {
return jailWith(root, nil, path)
}
// jailWith is jail with additional permitted roots, used by the read-only
// tools so a skill can read the files it ships with. Each extra root gets the
// same symlink resolution as the working root, so an extra root cannot be used
// to step sideways into the rest of the filesystem.
func jailWith(root string, extra []string, path string) (string, error) {
if root == "" {
return path, nil
}
// A file being created does not exist yet, so resolve the deepest existing
// ancestor and re-attach the rest.
probe, rest := path, ""
for {
if resolved, err := filepath.EvalSymlinks(probe); err == nil {
path = filepath.Join(resolved, rest)
break
}
parent := filepath.Dir(probe)
if parent == probe {
path = filepath.Clean(path)
break
}
rest = filepath.Join(filepath.Base(probe), rest)
probe = parent
}
realRoot := realpath(root)
if contains(realRoot, path) {
return path, nil
}
for _, e := range extra {
if e != "" && contains(realpath(e), path) {
return path, nil
}
}
if len(extra) > 0 {
return "", fmt.Errorf("%s is outside the working root %s and every skill directory; this harness only touches files under the root", path, realRoot)
}
return "", fmt.Errorf("%s is outside the working root %s; this harness only touches files under the root", path, realRoot)
}
// realpath resolves symlinks, falling back to a lexical clean for a path that
// does not exist.
func realpath(p string) string {
if resolved, err := filepath.EvalSymlinks(p); err == nil {
return resolved
}
return filepath.Clean(p)
}
// contains reports whether path is root or sits beneath it.
func contains(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
In internal/tools/read.go, change resolve and NewRead:
// resolve anchors a relative path to root and refuses anything outside it and
// outside every extra root.
func resolve(root string, extra []string, path string) (string, error) {
if path == "" {
return "", fmt.Errorf("path is required")
}
return jailWith(root, extra, resolveBase(root, path))
}
// NewRead builds the read_file tool. root anchors relative paths; track may be
// nil; extra names additional directories that may be read but never written.
func NewRead(root string, track ReadTracker, extra ...string) Tool {
and inside its closure, line 49 becomes:
path, err := resolve(root, extra, in.Path)
internal/tools/edit.go:27 and internal/tools/write.go:26 call resolve(root, in.Path); change both to resolve(root, nil, in.Path). They get no extra roots — a skill is input, not a work product.
In internal/tools/glob.go, change the signature to func NewGlob(root string, extra ...string) Tool and line 45 to:
base, err := jailWith(root, extra, resolveBase(root, in.Path))
In internal/tools/grep.go, change the signature to func NewGrep(root string, extra ...string) Tool and line 74 to:
base, err := jailWith(root, extra, resolveBase(root, in.Path))
Run: go test ./internal/tools/
Expected: PASS. The existing jail tests must still pass unchanged — if any fails, the refactor changed behaviour for the no-extra-roots case, which it must not.
Run: go build ./... && go test ./...
Expected: PASS. Existing call sites compile because the new parameters are variadic.
cd /home/joni/Work/GO/coding-harness
gofmt -w internal/tools/
git add internal/tools
git commit -m "tools: read-only extra roots in the path jail"
skill toolFiles:
internal/tools/skill.gointernal/tools/skill_test.gointernal/approval/policy.go:40-42internal/approval/policy_test.goInterfaces:
Consumes: skills.Skill and skills.Discover from Task 1.
Produces: tools.NewSkill(found []skills.Skill) Tool, registered under the name skill. Task 6 calls it. Returns nil when found is empty, and the caller must not register a nil tool.
Step 1: Write the failing tests
Create internal/tools/skill_test.go:
package tools
import (
"context"
"encoding/json"
"strings"
"testing"
"harness/internal/skills"
)
func testSkills() []skills.Skill {
return []skills.Skill{
{Name: "alpha", Description: "the first one", Body: "do the alpha thing", Dir: "/skills/alpha", Source: "personal"},
{Name: "beta", Description: "the second one", Body: "do the beta thing", Dir: "/skills/beta", Source: "project"},
}
}
func TestSkillReturnsBodyWithDirectory(t *testing.T) {
tool := NewSkill(testSkills())
out, err := tool.Execute(context.Background(), jsonArgs(t, map[string]any{"name": "alpha"}))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"Skill: alpha", "Directory: /skills/alpha", "do the alpha thing"} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q:\n%s", want, out)
}
}
}
func TestSkillAppendsArguments(t *testing.T) {
tool := NewSkill(testSkills())
out, err := tool.Execute(context.Background(), jsonArgs(t, map[string]any{
"name": "beta", "args": "fix the flaky test",
}))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "ARGUMENTS: fix the flaky test") {
t.Errorf("arguments missing:\n%s", out)
}
}
func TestSkillOmitsArgumentsLineWhenEmpty(t *testing.T) {
tool := NewSkill(testSkills())
out, _ := tool.Execute(context.Background(), jsonArgs(t, map[string]any{"name": "alpha"}))
if strings.Contains(out, "ARGUMENTS:") {
t.Errorf("empty args produced an ARGUMENTS line:\n%s", out)
}
}
func TestSkillUnknownNameListsTheRealOnes(t *testing.T) {
tool := NewSkill(testSkills())
_, err := tool.Execute(context.Background(), jsonArgs(t, map[string]any{"name": "nope"}))
if err == nil {
t.Fatal("unknown skill was accepted")
}
if !strings.Contains(err.Error(), "alpha") || !strings.Contains(err.Error(), "beta") {
t.Errorf("error does not list the available skills: %v", err)
}
}
func TestSkillSchemaEnumeratesSlugs(t *testing.T) {
tool := NewSkill(testSkills())
var schema struct {
Properties struct {
Name struct {
Enum []string `json:"enum"`
} `json:"name"`
} `json:"properties"`
}
if err := json.Unmarshal(tool.InputSchema(), &schema); err != nil {
t.Fatal(err)
}
if strings.Join(schema.Properties.Name.Enum, ",") != "alpha,beta" {
t.Errorf("enum = %v, want [alpha beta]", schema.Properties.Name.Enum)
}
}
func TestSkillDescriptionListsSkills(t *testing.T) {
tool := NewSkill(testSkills())
d := tool.Description()
if !strings.Contains(d, "alpha: the first one") || !strings.Contains(d, "beta: the second one") {
t.Errorf("tool description does not list the skills:\n%s", d)
}
}
func TestNewSkillWithNoneIsNil(t *testing.T) {
if NewSkill(nil) != nil {
t.Error("NewSkill(nil) must return nil so the caller registers nothing")
}
}
func TestSkillRepeatInvocationReturnsBodyAgain(t *testing.T) {
// Deduping would save tokens and cost the model the ability to re-read
// instructions that compaction dropped.
tool := NewSkill(testSkills())
first, _ := tool.Execute(context.Background(), jsonArgs(t, map[string]any{"name": "alpha"}))
second, _ := tool.Execute(context.Background(), jsonArgs(t, map[string]any{"name": "alpha"}))
if first != second || first == "" {
t.Error("the second invocation did not return the same body")
}
}
Add to internal/approval/policy_test.go:
func TestSkillRunsUnattended(t *testing.T) {
p := New(func(Request) Decision { t.Fatal("skill should not prompt"); return Deny })
if d := p.Decide(Request{Tool: "skill"}); d != Allow {
t.Errorf("skill decided %v, want Allow", d)
}
}
Match the existing helpers in policy_test.go — read it first and copy the shape of the neighbouring test for read_file, including the exact constructor and Request field names.
Run: go test ./internal/tools/ -run Skill -v && go test ./internal/approval/ -run Skill -v
Expected: FAIL — undefined: NewSkill, and the approval test fails because skill is not in the auto-approve set.
Create internal/tools/skill.go:
package tools
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"harness/internal/skills"
)
// SkillArgs is the skill input. The name is constrained to the discovered
// slugs by an enum patched into the schema, so a hallucinated name is a schema
// violation the model sees at once rather than a lookup failure it has to
// reason about.
type SkillArgs struct {
Name string `json:"name" jsonschema:"description=Which skill to load"`
Args string `json:"args,omitempty" jsonschema:"description=Arguments for the skill; whatever followed the skill name"`
}
// NewSkill builds the skill tool over the skills discovered at startup. It
// returns nil when there are none, so the caller registers nothing and the
// prompt is byte-identical to a harness without the feature.
func NewSkill(found []skills.Skill) Tool {
if len(found) == 0 {
return nil
}
byName := make(map[string]skills.Skill, len(found))
names := make([]string, 0, len(found))
var listing strings.Builder
for _, s := range found {
byName[s.Name] = s
names = append(names, s.Name)
fmt.Fprintf(&listing, "\n %s: %s", s.Name, s.Description)
}
sort.Strings(names)
desc := strings.TrimSpace(`
Load a skill: a set of instructions for one kind of work. Invoke a skill when
its description matches what you are about to do — before starting, not after.
The instructions come back as the tool result; follow them.
Available skills:`) + listing.String()
t := New("skill", desc, func(_ context.Context, in SkillArgs) (string, error) {
s, ok := byName[in.Name]
if !ok {
return "", fmt.Errorf("no skill named %q; available: %s", in.Name, strings.Join(names, ", "))
}
var b strings.Builder
fmt.Fprintf(&b, "Skill: %s\nDirectory: %s\n", s.Name, s.Dir)
b.WriteString("Join relative paths onto the directory above before reading them.\n\n")
b.WriteString(s.Body)
if a := strings.TrimSpace(in.Args); a != "" {
b.WriteString("\n\nARGUMENTS: " + a)
}
return b.String(), nil
})
return withEnum(t, "name", names)
}
// withEnum patches an enum onto one string property of a reflected schema.
// invopop/jsonschema derives the schema from struct tags, which cannot carry a
// list only known at startup.
func withEnum(t Tool, property string, values []string) Tool {
var m map[string]any
if err := json.Unmarshal(t.InputSchema(), &m); err != nil {
return t
}
props, ok := m["properties"].(map[string]any)
if !ok {
return t
}
p, ok := props[property].(map[string]any)
if !ok {
return t
}
p["enum"] = values
patched, err := json.Marshal(m)
if err != nil {
return t
}
return patchedSchema{Tool: t, schema: patched}
}
// patchedSchema wraps a Tool to override only its input schema.
type patchedSchema struct {
Tool
schema json.RawMessage
}
func (p patchedSchema) InputSchema() json.RawMessage { return p.schema }
skill to the auto-approved setIn internal/approval/policy.go, the map at lines 40-42 becomes:
"read_file": true,
"glob": true,
"grep": true,
// skill reads one file from a directory found at startup, and cannot
// reach anything read_file could not already reach.
"skill": true,
Run: go test ./internal/tools/ ./internal/approval/ -v
Expected: PASS.
cd /home/joni/Work/GO/coding-harness
gofmt -w internal/tools internal/approval
git add internal/tools internal/approval
git commit -m "tools: a skill tool, auto-approved, with a slug enum"
Files:
internal/agent/loop.go (Options at line 42, New at line 112)internal/agent/loop_test.goInterfaces:
Consumes: skills.Skill from Task 1.
Produces: agent.Options.Skills []skills.Skill. Task 6 sets it. The listing is appended to opts.System inside New, so request() is untouched and the system prompt is trivially constant for the session.
Step 1: Write the failing tests
Add to internal/agent/loop_test.go:
func TestSystemPromptListsSkills(t *testing.T) {
a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{
Skills: []skills.Skill{{Name: "commit-helper", Description: "Use when writing commit messages"}},
})
sys := a.opts.System
if !strings.Contains(sys, "commit-helper: Use when writing commit messages") {
t.Errorf("system prompt does not list the skill:\n%s", sys)
}
if !strings.Contains(sys, "/<name>") {
t.Error("system prompt does not explain what a leading slash means")
}
if !strings.HasPrefix(sys, defaultSystem) {
t.Error("the listing replaced the default prompt instead of being appended to it")
}
}
func TestSystemPromptUnchangedWithoutSkills(t *testing.T) {
a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{})
if a.opts.System != defaultSystem {
t.Errorf("system prompt differs from the default with no skills:\n%s", a.opts.System)
}
}
func TestSystemPromptIsStableAcrossRequests(t *testing.T) {
// The listing sits in the cached prefix. If it varied per request, every
// turn would miss the cache.
a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{
Skills: []skills.Skill{{Name: "a", Description: "one"}, {Name: "b", Description: "two"}},
})
a.state.Append(llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("hi")}})
first := a.request().System[0].Text
second := a.request().System[0].Text
if first != second {
t.Error("the system prompt changed between two requests in one session")
}
}
Add "harness/internal/skills" and "strings" to the test file's imports if they are not already there.
Run: go test ./internal/agent/ -run SystemPrompt -v
Expected: FAIL to compile — unknown field Skills in struct literal.
In internal/agent/loop.go, add to Options:
// Skills are the instruction sets discovered at startup. Their names and
// descriptions are appended to the system prompt; the bodies are loaded on
// demand by the skill tool.
Skills []skills.Skill
Add "harness/internal/skills" and "strings" to the imports, and in New, right after the existing if opts.System == "" { opts.System = defaultSystem }:
opts.System += skillListing(opts.Skills)
Then add, next to defaultSystem:
// skillListing is appended to the system prompt when skills were discovered.
// It is constant for the session, so the cached prefix still matches request to
// request: what breaks caching is content that varies per request, not content
// that varies per session.
func skillListing(found []skills.Skill) string {
if len(found) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\nSkills are instruction sets for specific kinds of work. Invoke one with\nthe skill tool when its description matches what you are doing — before\nstarting, not after. A user message beginning with /<name> is a request to\ninvoke that skill; everything after the name is its arguments.\n\nAvailable skills:")
for _, s := range found {
fmt.Fprintf(&b, "\n %s: %s", s.Name, s.Description)
}
return b.String()
}
fmt is already imported by loop.go; confirm before adding it again.
Run: go test ./internal/agent/ -v
Expected: PASS, including the existing compaction and loop tests.
cd /home/joni/Work/GO/coding-harness
gofmt -w internal/agent
git add internal/agent
git commit -m "agent: list discovered skills in the cached system prompt"
/skills and /slug in the TUIFiles:
internal/tui/model.go (submit at line 426, helpText at line 546, Options struct)internal/tui/model_test.goInterfaces:
skills.Skill from Task 1.Skills []skills.Skill field on the TUI's options struct. Task 6 sets it.Read internal/tui/model.go around line 100 to find the options struct's real name and how m.opts is populated before writing this; the plan calls it Options but the field must go wherever m.opts comes from.
Add to internal/tui/model_test.go, following the shape of the existing /help test at line 231:
func TestSlashSkillsListsThem(t *testing.T) {
m := newTestModel(t)
m.opts.Skills = []skills.Skill{
{Name: "commit-helper", Description: "Use when writing commit messages", Source: "personal"},
}
m.submit("/skills")
out := m.tr.String()
if !strings.Contains(out, "commit-helper") || !strings.Contains(out, "Use when writing commit messages") {
t.Errorf("/skills did not list the skill:\n%s", out)
}
}
func TestSlashSkillsWithNoneSaysSo(t *testing.T) {
m := newTestModel(t)
m.submit("/skills")
if !strings.Contains(m.tr.String(), "no skills") {
t.Errorf("/skills with none found said nothing useful:\n%s", m.tr.String())
}
}
func TestKnownSkillIsSentAsAUserTurn(t *testing.T) {
m := newTestModel(t)
m.opts.Skills = []skills.Skill{{Name: "commit-helper", Description: "d"}}
m.submit("/commit-helper fix the flaky test")
if m.phase != phaseRunning {
t.Errorf("phase = %v, want phaseRunning — the line was not sent", m.phase)
}
if !strings.Contains(m.tr.String(), "/commit-helper fix the flaky test") {
t.Errorf("the user turn is not in the transcript:\n%s", m.tr.String())
}
}
func TestUnknownSlashCommandStillErrors(t *testing.T) {
m := newTestModel(t)
m.opts.Skills = []skills.Skill{{Name: "commit-helper", Description: "d"}}
m.submit("/commit-helpr")
if m.phase == phaseRunning {
t.Error("a typo was sent to the model instead of being caught")
}
if !strings.Contains(m.tr.String(), "unknown command") {
t.Errorf("no error for the typo:\n%s", m.tr.String())
}
}
func TestHelpMentionsSkills(t *testing.T) {
m := newTestModel(t)
m.submit("/help")
if !strings.Contains(m.tr.String(), "/skills") {
t.Error("/help does not mention /skills")
}
}
newTestModel and the transcript-to-string helper may have different names — read the top of internal/tui/model_test.go and the existing /help test, and use whatever those tests already use rather than inventing new helpers.
Run: go test ./internal/tui/ -run 'Skill|Help' -v
Expected: FAIL — m.opts.Skills undefined.
Add to the TUI options struct:
// Skills are the instruction sets discovered at startup, used to resolve
// /<slug> and to answer /skills.
Skills []skills.Skill
In submit, add the /skills case anywhere among the existing command cases:
case line == "/skills":
if len(m.opts.Skills) == 0 {
m.tr.addNotice("no skills found in ~/" + skills.Dirname + " or ./" + skills.Dirname)
} else {
var b strings.Builder
b.WriteString("skills:")
for _, s := range m.opts.Skills {
fmt.Fprintf(&b, "\n /%s (%s) \u2014 %s", s.Name, s.Source, s.Description)
}
m.tr.addNotice(b.String())
}
return m, m.sync()
Then make one change to the existing unknown-command case at line 448 — add the guard, and nothing else:
case strings.HasPrefix(line, "/") && !m.isSkill(line):
m.tr.addError("unknown command " + line + " — /help lists them")
return m, m.sync()
}
A recognised skill now matches no case, falls out of the switch, and reaches
the existing user-turn code at the bottom of submit untouched. The model sees
the slash, the system prompt explains what it means, and the skill tool does
the loading — one code path for both ways in.
Add the helper next to submit:
// isSkill reports whether a slash command names a discovered skill. Matching
// here rather than sending every unrecognised slash to the model means a typo
// is caught by the harness instead of puzzled over by the agent.
func (m *model) isSkill(line string) bool {
name := strings.TrimPrefix(line, "/")
if i := strings.IndexAny(name, " \t"); i >= 0 {
name = name[:i]
}
for _, s := range m.opts.Skills {
if s.Name == name {
return true
}
}
return false
}
Update helpText at line 546:
const helpText = `commands: /allowed /clear /help /skills /exit
Keep the rest of that const exactly as it is. Add "harness/internal/skills" and "fmt" to the imports if absent.
Run: go test ./internal/tui/ -v
Expected: PASS, including the existing look and colour tests.
cd /home/joni/Work/GO/coding-harness
gofmt -w internal/tui
git add internal/tui
git commit -m "tui: /skills, and /<slug> as a user turn"
Files:
cmd/harness/main.go (flags near line 55, wiring at lines 101-130)README.mdInterfaces:
Consumes: everything from Tasks 1-5.
Produces: the working feature. Nothing depends on this task.
Step 1: Add the flag
In cmd/harness/main.go, after the -no-tui flag at line 55:
noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+" and ./"+skills.Dirname)
Replace the registry construction at lines 101-109:
var found []skills.Skill
if !*noSkills {
home, err := os.UserHomeDir()
if err != nil {
// No home directory is not fatal: project skills still work.
home = ""
}
var problems []error
found, problems = skills.Discover(home, root)
for _, p := range problems {
notice("skill skipped: %v", p)
}
}
// Skill directories are readable by the search tools and by nothing else,
// so a skill can read the files it ships with.
var skillDirs []string
for _, s := range found {
skillDirs = append(skillDirs, s.Dir)
}
state := agent.NewState()
reg := tools.NewRegistry(log).Register(
tools.NewRead(root, state, skillDirs...),
tools.NewGlob(root, skillDirs...),
tools.NewGrep(root, skillDirs...),
tools.NewEdit(root, state),
tools.NewWrite(root, state),
tools.NewBash(root),
)
if t := tools.NewSkill(found); t != nil {
reg.Register(t)
}
Add Skills: found, to the agent.Options literal at line 123.
Find where the TUI options are built further down main.go (search for the struct the TUI's m.opts comes from) and add Skills: found, there too.
Confirm "harness/internal/skills" and "os" are imported.
Run: go build ./... && go test ./...
Expected: PASS.
cd /home/joni/Work/GO/coding-harness
mkdir -p /tmp/skilldemo/.agents/skills/demo-skill/references
cat > /tmp/skilldemo/.agents/skills/demo-skill/SKILL.md <<'EOF'
---
name: demo-skill
description: Use when demonstrating that skills load
---
Say the word BANANA, then read references/note.md and say what it contains.
EOF
echo "the note says pineapple" > /tmp/skilldemo/.agents/skills/demo-skill/references/note.md
git -C /tmp/skilldemo init -q
go build -o harness ./cmd/harness
./harness -C /tmp/skilldemo -no-tui -no-skills -p "/demo-skill" 2>&1 | head -5
Expected with -no-skills: the model is told nothing about skills and has no tool, so it treats /demo-skill as ordinary text.
Then, with a real key and without the flag:
./harness -C /tmp/skilldemo -no-tui -p "/demo-skill"
Expected: a Skill(name=demo-skill) call, then a ReadFile of the bundled references/note.md, then a reply mentioning both BANANA and pineapple. The read of references/note.md is the proof that the jail change works.
/skills in the TUI./harness -C /tmp/skilldemo
Type /skills — it must list demo-skill (project). Type /demo-skil — it must say unknown command. Type /help — it must list /skills.
Add /skills to the commands line (Commands: /allowed, /clear, /help, /exit) and -no-skills to the useful-flags line. Add a ## Skills section after ## Tools:
## Skills
A skill is a directory of instructions the agent pulls in when it is relevant:
```
~/.agents/skills/commit-helper/SKILL.md every repo
./.agents/skills/commit-helper/SKILL.md this one, checked in
```
```markdown
---
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.
```
The directory name is the skill's name, and a `name:` that disagrees with it is
a load error. Only the description reaches the model until the skill is
invoked, so it is what decides whether the skill fires at all; the body can be
as long as you like.
Two ways in, one way to load. The agent calls the `skill` tool when a
description matches what it is doing; you can force one with `/commit-helper
fix the flaky test`, which the model answers by making the same call. `/skills`
lists what was found, project shadowing personal on a name collision.
Skill directories are readable by `read_file`, `glob` and `grep` even though
they sit outside the working root, so a skill can ship reference files and
point at them. They are not writable by anything: `edit_file`, `write_file` and
`bash` keep the working root as their only root. A skill is input, not a work
product.
Discovery happens at startup. The name-and-description listing goes in the
cached prompt prefix, so re-reading the directory each turn would cost a cache
miss on every turn for a file that never changes. Restart to pick up a new
skill. `-no-skills` turns the whole thing off; the eval runner never discovers
skills at all, so METRICS.md is unaffected by what is in your home directory.
Add internal/skills discovery and parsing of SKILL.md to the layout block near the end of the README.
cd /home/joni/Work/GO/coding-harness
gofmt -w ./cmd ./internal
go build ./... && go test ./...
git add cmd README.md
git commit -m "harness: discover and wire up skills, document them"
Spec coverage: Format → Task 1. Discovery and precedence → Task 1. The skill tool, enum, preamble, ARGUMENTS:, repeat invocation → Task 3. System prompt listing and cache stability → Task 4. User invocation, /skills, /help, line-based front end → Task 5. Path jail and the per-tool table → Task 2. Flags → Task 6. Dependency → Task 1 Step 1. Package shape → Task 1. Testing → the test steps in Tasks 1-5, plus manual verification in Task 6. Out of scope → nothing planned. No gaps.
Type consistency: skills.Skill{Name, Description, Body, Dir, Source} and skills.Discover(home, workroot) ([]Skill, []error) are defined in Task 1 and used with those exact names in Tasks 3, 4, 5 and 6. tools.NewSkill(found []skills.Skill) Tool is defined in Task 3 and called in Task 6. jailWith(root, extra, path) is defined in Task 2 and used in Tasks 2 only. skills.Dirname is defined in Task 1 and used in Tasks 5 and 6. The variadic constructors from Task 2 are called with skillDirs... in Task 6.
Known soft spots for the implementer: Tasks 3 and 5 depend on names this plan could not read — the approval.Request/Decide shape in policy_test.go, the TUI options struct's real name, and the newTestModel/transcript helpers in model_test.go. Each step says to read the neighbouring code and match it rather than trusting the plan's guess.