Vectrum
Sign In
1414 lines · 45.9 KB

Skills Implementation Plan

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

Global Constraints

  • Go module is harness; import paths are harness/internal/....
  • internal/agent must never import internal/ui or internal/tui. It may import internal/skills.
  • Every tool returns (string, error) where the error is text the model reads.
  • Slugs match ^[a-z0-9][a-z0-9-]*$.
  • Frontmatter name must equal the directory name, or the skill is a problem.
  • Skills are readable, never writable: edit_file, write_file and bash get no extra roots.
  • Nothing about skills aborts startup. Every discovery failure is a problem, not an error.
  • Discovery is startup-only. No hot reload.
  • With zero skills discovered: no tool registered, nothing appended to the system prompt, no extra jail roots. The prompt must be byte-identical to today's.
  • New exported identifiers get a doc comment, matching the existing house style.
  • Run gofmt -w on every file you touch before committing.

Task 1: The internal/skills package

Files:

  • Create: internal/skills/skills.go
  • Create: internal/skills/skills_test.go
  • Modify: go.mod, go.sum

Interfaces:

  • 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
  • Step 2: Write the failing tests

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)
	}
}
  • Step 3: Run the tests to verify they fail

Run: go test ./internal/skills/ Expected: FAIL — undefined: Discover, undefined: Skill.

  • Step 4: Write the implementation

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
}
  • Step 5: Run the tests to verify they pass

Run: go test ./internal/skills/ -v Expected: PASS, all cases.

  • Step 6: Commit
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"

Task 2: Read-only extra roots in the path jail

Files:

  • Modify: internal/tools/read.go (jail at line 141, resolve at line 114, NewRead at line 40)
  • Modify: internal/tools/glob.go:45
  • Modify: internal/tools/grep.go:74
  • Test: internal/tools/jail_test.go

Interfaces:

  • Consumes: nothing from Task 1.
  • Produces: 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.

  • Step 1: Write the failing tests

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.

  • Step 2: Run the tests to verify they fail

Run: go test ./internal/tools/ -run 'ExtraRoot|GlobAndGrep' -v Expected: FAIL to compile — too many arguments in call to NewRead.

  • Step 3: Replace jail with jailWith in internal/tools/read.go

Replace 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))
}
  • Step 4: Thread extra roots through the three read tools

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))
  • Step 5: Run the whole tools suite

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.

  • Step 6: Verify nothing else broke

Run: go build ./... && go test ./... Expected: PASS. Existing call sites compile because the new parameters are variadic.

  • Step 7: Commit
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"

Task 3: The skill tool

Files:

  • Create: internal/tools/skill.go
  • Create: internal/tools/skill_test.go
  • Modify: internal/approval/policy.go:40-42
  • Test: internal/approval/policy_test.go

Interfaces:

  • 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.

  • Step 2: Run the tests to verify they fail

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.

  • Step 3: Write the tool

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("Relative paths below resolve against that directory.\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 }
  • Step 4: Add skill to the auto-approved set

In 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,
  • Step 5: Run the tests to verify they pass

Run: go test ./internal/tools/ ./internal/approval/ -v Expected: PASS.

  • Step 6: Commit
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"

Task 4: Tell the model skills exist

Files:

  • Modify: internal/agent/loop.go (Options at line 42, New at line 112)
  • Test: internal/agent/loop_test.go

Interfaces:

  • 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.

  • Step 2: Run the tests to verify they fail

Run: go test ./internal/agent/ -run SystemPrompt -v Expected: FAIL to compile — unknown field Skills in struct literal.

  • Step 3: Add the option and compose the prompt

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.

  • Step 4: Run the tests to verify they pass

Run: go test ./internal/agent/ -v Expected: PASS, including the existing compaction and loop tests.

  • Step 5: Commit
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"

Task 5: /skills and /slug in the TUI

Files:

  • Modify: internal/tui/model.go (submit at line 426, helpText at line 546, Options struct)
  • Test: internal/tui/model_test.go

Interfaces:

  • Consumes: skills.Skill from Task 1.
  • Produces: a 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.

  • Step 1: Write the failing tests

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.

  • Step 2: Run the tests to verify they fail

Run: go test ./internal/tui/ -run 'Skill|Help' -v Expected: FAIL — m.opts.Skills undefined.

  • Step 3: Add the field and the two cases

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.

  • Step 4: Run the tests to verify they pass

Run: go test ./internal/tui/ -v Expected: PASS, including the existing look and colour tests.

  • Step 5: Commit
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"

Task 6: Wire it up, and document it

Files:

  • Modify: cmd/harness/main.go (flags near line 55, wiring at lines 101-130)
  • Modify: README.md
  • Test: manual, per the steps below

Interfaces:

  • 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)
  • Step 2: Discover, and wire the results through

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.

  • Step 3: Build and run the whole suite

Run: go build ./... && go test ./... Expected: PASS.

  • Step 4: Verify the feature by hand against the mock API
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.

  • Step 5: Verify /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.

  • Step 6: Document it in the README

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.

  • Step 7: Commit
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"

Self-Review

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.