diff --git a/.gitignore b/.gitignore index fb5842b..c9e9ace 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,8 @@ dist/ coverage.out coverage.html -# IDE +# IDE / agent workspaces +.claude/ .idea/ .vscode/ *.swp diff --git a/AGENTS.md b/AGENTS.md index e2c8ecd..222b2d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,7 +169,7 @@ On subsequent encounters, the agent finds the existing skill via `skern skill se --- name: skill-name description: | - What this skill does and when to use it + Use when . allowed-tools: [] metadata: author: @@ -184,13 +184,41 @@ metadata: date: "2025-07-15T10:30:00Z" --- -## Instructions +## Overview -Step-by-step instructions for the agent. +1-2 sentence core principle of the skill. + +## When to Use + +- Triggering conditions and symptoms + +## Core Pattern + +The main technique or pattern (before/after for techniques). + +## Quick Reference + +- Scannable summary for fast lookup + +## Common Mistakes + +- Frequent errors and fixes ``` Required fields: `name`, `description`. Directory name must match the `name` field. +### Writing Skills Guidelines + +When creating or editing skills (via `skern skill create` or manually), follow these guidelines adapted from [superpowers/writing-skills](https://github.com/obra/superpowers/tree/main/skills/writing-skills). For full details, see [docs/writing-skills.md](docs/writing-skills.md). + +Key points: + +- **Description**: Start with "Use when..." — describe triggering conditions, not a workflow summary +- **Naming**: `kebab-case`, verb-first active voice (`creating-skills` not `skill-creation`) +- **Body structure**: Overview → When to Use → Core Pattern → Quick Reference → Common Mistakes +- **Token budget**: Getting-started < 150 words; frequently-loaded < 200 words; others < 500 words +- **Examples**: One excellent example beats many mediocre ones + ### Skill Name Validation Names must match `^[a-z0-9]+(-[a-z0-9]+)*$` and be 1-64 characters. diff --git a/docs/writing-skills.md b/docs/writing-skills.md new file mode 100644 index 0000000..23d9651 --- /dev/null +++ b/docs/writing-skills.md @@ -0,0 +1,152 @@ +# Writing Effective Skills + +This guide covers how to write high-quality, discoverable, well-structured skills for skern. These guidelines are adapted from the [superpowers writing-skills](https://github.com/obra/superpowers/tree/main/skills/writing-skills) project and are built into skern's scaffolding and validation. + +## Skill Types + +| Type | Description | Example Name | +|------|-------------|--------------| +| **Technique** | Concrete method with actionable steps | `condition-based-waiting` | +| **Pattern** | Mental model for problem-solving | `flatten-with-flags` | +| **Reference** | API docs, syntax guides, lookup tables | `office-docs` | + +## When to Create a Skill + +Create a skill when: + +- The technique was not intuitively obvious +- You would reference it across multiple projects +- The pattern applies broadly beyond one codebase +- Others would benefit from it + +Do **not** create a skill for: + +- One-time solutions or narratives about specific sessions +- Patterns already well-documented in official tooling + +## Description: Claude Search Optimization (CSO) + +The `description` field in your SKILL.md frontmatter is the most critical field for agent discoverability. Agents match skills by description, so how you write it directly affects whether your skill gets loaded. + +**Start with "Use when..."** and describe the triggering conditions: + +```yaml +# Good +description: | + Use when tests hang or flake due to timing-dependent assertions. + Symptoms: intermittent CI failures, setTimeout in test code. + +# Bad — summarizes the workflow (agents may shortcut) +description: | + A skill that teaches agents how to write better async tests + using condition-based waiting patterns. +``` + +**Why this matters:** When a description summarizes the skill's workflow, an agent may follow the description as a shortcut instead of reading the full SKILL.md body. + +**Keyword tips:** + +- Include error messages agents might encounter +- Add symptom words: "flaky", "hanging", "race condition", "timeout" +- Use tool names and common synonyms + +## Naming Conventions + +- Use `kebab-case` with lowercase letters, numbers, and hyphens +- Prefer verb-first active voice: `creating-skills` not `skill-creation` +- Be specific: `condition-based-waiting` not `async-test-helpers` + +Names must match `^[a-z0-9]+(-[a-z0-9]+)*$` and be 1-64 characters. + +## Recommended Body Structure + +When you run `skern skill create`, the scaffold includes these sections by default: + +### Overview + +1-2 sentences describing the core principle or technique. Keep it concise. + +### When to Use + +List triggering conditions as bullet points: + +- Symptoms that signal this skill is needed +- Specific use cases +- When **not** to use this skill (counter-examples help agents avoid false matches) + +### Core Pattern + +The main technique or pattern. For techniques, use a before/after comparison: + +```markdown +## Core Pattern + +**Before (anti-pattern):** +`setTimeout(check, 1000)` — arbitrary delay, still flaky + +**After (correct):** +`waitFor(() => expect(value).toBe(true))` — condition-based, deterministic +``` + +### Quick Reference + +A scannable table or bullet list for fast lookup. Agents should be able to use this section as a cheat sheet without reading the full skill. + +### Common Mistakes + +Frequent errors and their fixes. Helps agents self-correct. + +## Token Efficiency + +Skills are loaded into agent context windows, so brevity matters: + +| Skill Category | Target Word Count | +|----------------|-------------------| +| Getting-started workflows | < 150 words | +| Frequently-loaded skills | < 200 words | +| Other skills | < 500 words | + +**Tips:** + +- Use cross-references instead of repeating shared concepts +- Compress examples to the minimum that demonstrates the point +- Move heavy reference material (100+ lines) to supporting files + +## Code Examples + +- **One excellent example beats many mediocre ones** +- Use the most relevant language for the skill's domain +- Keep examples copy-pasteable and runnable + +## File Organization + +| Layout | When to Use | +|--------|------------| +| Single `SKILL.md` | All content fits in one file | +| `SKILL.md` + supporting files | Heavy reference (100+ lines) or reusable tool code | + +Supporting files live in the same directory as `SKILL.md`. Reference them in the body. + +## Anti-Patterns to Avoid + +- **Narrative storytelling** — "In session 2025-10-03, I encountered..." Skills are reusable references, not journals. +- **Multi-language examples** — Pick one language. Multiple languages dilute quality and waste tokens. +- **Generic labels** — Avoid `helper1`, `step3`, `utils`. Use descriptive names. +- **Workflow summaries in description** — The description field is for triggering conditions only. + +## Validation Hints + +Skern's validator provides hints when your skill deviates from these guidelines: + +- **Description missing trigger prefix** — Hints when the description doesn't start with "Use when", "Use for", "Use to", etc. +- **Missing recommended sections** — Hints when the body is missing "When to Use", "Core Pattern", "Quick Reference", or "Common Mistakes" sections. +- **Body too short** — Hints when the body has fewer than 20 words. +- **Description too vague** — Hints when the description has fewer than 3 words. + +These are non-blocking hints (not errors), designed to guide you toward better skills. + +## Next Steps + +- [Quick Start](/guide/quick-start) — create and install your first skill +- [Skill Format](/concepts/skill-format) — full SKILL.md specification +- [Validation Reference](/reference/validation) — all validation rules diff --git a/internal/skill/scaffold.go b/internal/skill/scaffold.go index 8f94d72..513b289 100644 --- a/internal/skill/scaffold.go +++ b/internal/skill/scaffold.go @@ -1,8 +1,46 @@ package skill // DefaultBody returns the default body content for a new skill. +// The template follows the writing-skills guidelines: Overview, When to Use, +// Core Pattern, Quick Reference, and Common Mistakes sections. func DefaultBody() string { - return "## Instructions\n\nTODO: Add step-by-step instructions for the agent.\n" + return `## Overview + + + +TODO: Describe the core principle of this skill. + +## When to Use + + + +- TODO: Add triggering conditions + +## Core Pattern + + + +TODO: Describe the core pattern or technique. + +## Quick Reference + + + +- TODO: Add quick reference items + +## Common Mistakes + + + +- TODO: Add common mistakes and how to avoid them +` +} + +// defaultDescription returns the default placeholder description for a new skill. +// Follows the writing-skills guideline: start with "Use when..." to describe +// triggering conditions, not a workflow summary. +func defaultDescription() string { + return "Use when TODO: describe the triggering conditions for this skill.\n" } // NewSkill creates a new Skill with sensible defaults. @@ -13,7 +51,7 @@ func NewSkill(name, description, authorName, authorType, authorPlatform string) // NewSkillWithBody creates a new Skill with a custom body. If body is empty, DefaultBody() is used. func NewSkillWithBody(name, description, authorName, authorType, authorPlatform, body string) *Skill { if description == "" { - description = "TODO: Describe what this skill does and when to use it.\n" + description = defaultDescription() } if body == "" { diff --git a/internal/skill/scaffold_test.go b/internal/skill/scaffold_test.go index ab58799..a67c743 100644 --- a/internal/skill/scaffold_test.go +++ b/internal/skill/scaffold_test.go @@ -10,9 +10,14 @@ func TestNewSkill_NameOnly(t *testing.T) { s := NewSkill("my-skill", "", "", "", "") assert.Equal(t, "my-skill", s.Name) + assert.Contains(t, s.Description, "Use when") assert.Contains(t, s.Description, "TODO") assert.Equal(t, "0.0.1", s.Metadata.Version) - assert.Contains(t, s.Body, "## Instructions") + assert.Contains(t, s.Body, "## Overview") + assert.Contains(t, s.Body, "## When to Use") + assert.Contains(t, s.Body, "## Core Pattern") + assert.Contains(t, s.Body, "## Quick Reference") + assert.Contains(t, s.Body, "## Common Mistakes") } func TestNewSkill_WithDescription(t *testing.T) { @@ -39,6 +44,17 @@ func TestNewSkill_WithAgentAuthor(t *testing.T) { func TestDefaultBody(t *testing.T) { body := DefaultBody() - assert.Contains(t, body, "## Instructions") + assert.Contains(t, body, "## Overview") + assert.Contains(t, body, "## When to Use") + assert.Contains(t, body, "## Core Pattern") + assert.Contains(t, body, "## Quick Reference") + assert.Contains(t, body, "## Common Mistakes") assert.Contains(t, body, "TODO") } + +func TestDefaultDescription(t *testing.T) { + desc := defaultDescription() + assert.True(t, len(desc) > 0) + assert.Contains(t, desc, "Use when") + assert.Contains(t, desc, "TODO") +} diff --git a/internal/skill/validator.go b/internal/skill/validator.go index 494bfca..c7fecae 100644 --- a/internal/skill/validator.go +++ b/internal/skill/validator.go @@ -6,6 +6,7 @@ import ( "path/filepath" "regexp" "strings" + "unicode" ) // Severity represents the severity of a validation issue. @@ -139,6 +140,52 @@ const ( lintDescMinWords = 3 ) +// descriptionTriggerPrefixes are the recommended prefixes for skill descriptions. +// Descriptions should start with a triggering condition (e.g., "Use when...") to +// optimize agent discovery (Claude Search Optimization). Summaries of what the +// skill does cause agents to follow the description shortcut instead of reading +// the full SKILL.md. +var descriptionTriggerPrefixes = []string{ + "use when", + "use for", + "use to", + "trigger when", + "apply when", +} + +// recommendedBodySections lists the sections recommended by the writing-skills +// guidelines. These improve discoverability, scannability, and quality. +var recommendedBodySections = []string{ + "overview", + "when to use", + "core pattern", + "quick reference", + "common mistakes", +} + +// hasMarkdownHeading checks whether the body contains a markdown heading (any level) +// whose text matches the given section name (case-insensitive). +func hasMarkdownHeading(body, section string) bool { + sectionLower := strings.ToLower(section) + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(line) + // Strip leading '#' characters + stripped := strings.TrimLeft(trimmed, "#") + if stripped == trimmed { + continue // no '#' prefix — not a heading + } + // Must have at least one space after '#'s + if len(stripped) == 0 || !unicode.IsSpace(rune(stripped[0])) { + continue + } + headingText := strings.TrimSpace(stripped) + if strings.ToLower(headingText) == sectionLower { + return true + } + } + return false +} + // lintStyle performs stylistic quality checks on a skill. // Issues use SeverityHint to distinguish from structural errors/warnings. func lintStyle(s *Skill) []ValidationIssue { @@ -155,7 +202,8 @@ func lintStyle(s *Skill) []ValidationIssue { } // Description too vague (very short) - descWords := len(strings.Fields(strings.TrimSpace(s.Description))) + descTrimmed := strings.TrimSpace(s.Description) + descWords := len(strings.Fields(descTrimmed)) if descWords > 0 && descWords < lintDescMinWords { issues = append(issues, ValidationIssue{ Field: "description", @@ -164,6 +212,25 @@ func lintStyle(s *Skill) []ValidationIssue { }) } + // Description should start with a triggering condition (CSO guideline) + if descWords >= lintDescMinWords { + descLower := strings.ToLower(descTrimmed) + hasTriggerPrefix := false + for _, prefix := range descriptionTriggerPrefixes { + if strings.HasPrefix(descLower, prefix) { + hasTriggerPrefix = true + break + } + } + if !hasTriggerPrefix { + issues = append(issues, ValidationIssue{ + Field: "description", + Severity: SeverityHint, + Message: "description should start with a trigger phrase (\"Use when\", \"Use for\", \"Use to\", \"Trigger when\", or \"Apply when\") to describe triggering conditions, not summarize the workflow", + }) + } + } + // Body lacks step-by-step guidance markers bodyLower := strings.ToLower(s.Body) hasSteps := strings.Contains(bodyLower, "step") || @@ -178,6 +245,23 @@ func lintStyle(s *Skill) []ValidationIssue { }) } + // Body should include recommended sections (writing-skills guideline) + if bodyWords >= lintBodyMinWords { + var missing []string + for _, section := range recommendedBodySections { + if !hasMarkdownHeading(s.Body, section) { + missing = append(missing, section) + } + } + if len(missing) > 0 { + issues = append(issues, ValidationIssue{ + Field: "body", + Severity: SeverityHint, + Message: fmt.Sprintf("body is missing recommended sections: %s", strings.Join(missing, ", ")), + }) + } + } + return issues } diff --git a/internal/skill/validator_test.go b/internal/skill/validator_test.go index e59a6d4..fc0cb29 100644 --- a/internal/skill/validator_test.go +++ b/internal/skill/validator_test.go @@ -11,8 +11,29 @@ import ( func TestValidate_ValidSkill(t *testing.T) { s := &Skill{ Name: "my-skill", - Description: "A valid skill description", - Body: "## Instructions\n\nThis skill provides step-by-step guidance for completing tasks:\n\n- First, analyze the input carefully and validate the data\n- Then, process the data accordingly and format the results\n- Finally, return the formatted output to the user", + Description: "Use when you need to complete tasks with step-by-step guidance", + Body: `## Overview + +This skill provides step-by-step guidance for completing tasks. + +## When to Use + +- When you need structured task completion + +## Core Pattern + +- First, analyze the input carefully and validate the data +- Then, process the data accordingly and format the results +- Finally, return the formatted output to the user + +## Quick Reference + +- Analyze → Process → Format → Return + +## Common Mistakes + +- Skipping validation of input data +`, Metadata: Metadata{ Author: Author{Name: "alice", Type: "human"}, Version: "1.0.0", @@ -268,15 +289,18 @@ func TestValidationIssue_String(t *testing.T) { func TestLintStyle_BodyTooShort(t *testing.T) { s := &Skill{ Name: "my-skill", - Description: "A valid skill description", + Description: "Use when you need a valid skill description", Body: "## Instructions\n\nDo something useful.", } issues := lintStyle(s) - require.Len(t, issues, 1) - assert.Equal(t, "body", issues[0].Field) - assert.Equal(t, SeverityHint, issues[0].Severity) - assert.Contains(t, issues[0].Message, "words") + hasBodyShortHint := false + for _, i := range issues { + if i.Field == "body" && i.Severity == SeverityHint && strings.Contains(i.Message, "words") { + hasBodyShortHint = true + } + } + assert.True(t, hasBodyShortHint) } func TestLintStyle_DescriptionTooVague(t *testing.T) { @@ -287,33 +311,171 @@ func TestLintStyle_DescriptionTooVague(t *testing.T) { } issues := lintStyle(s) - require.Len(t, issues, 1) - assert.Equal(t, "description", issues[0].Field) - assert.Equal(t, SeverityHint, issues[0].Severity) - assert.Contains(t, issues[0].Message, "word") + hasDescVague := false + for _, i := range issues { + if i.Field == "description" && i.Severity == SeverityHint && strings.Contains(i.Message, "word") { + hasDescVague = true + } + } + assert.True(t, hasDescVague) } func TestLintStyle_BodyLacksSteps(t *testing.T) { s := &Skill{ Name: "my-skill", - Description: "A valid skill description", + Description: "Use when you need to do something specific", Body: strings.Repeat("word ", 25), } issues := lintStyle(s) - require.Len(t, issues, 1) - assert.Equal(t, "body", issues[0].Field) - assert.Equal(t, SeverityHint, issues[0].Severity) - assert.Contains(t, issues[0].Message, "step-by-step") + hasStepHint := false + for _, i := range issues { + if i.Field == "body" && i.Severity == SeverityHint && strings.Contains(i.Message, "step-by-step") { + hasStepHint = true + } + } + assert.True(t, hasStepHint) } func TestLintStyle_NoHints(t *testing.T) { s := &Skill{ Name: "my-skill", - Description: "A valid skill description", - Body: "## Instructions\n\nThis skill provides step-by-step guidance for completing tasks:\n\n- First, analyze the input carefully and validate the data\n- Then, process the data accordingly and format the results\n- Finally, return the formatted output to the user", + Description: "Use when you need step-by-step task guidance", + Body: `## Overview + +This skill provides step-by-step guidance for completing tasks. + +## When to Use + +- When you need structured task completion + +## Core Pattern + +- First, analyze the input carefully and validate the data +- Then, process the data accordingly and format the results + +## Quick Reference + +- Analyze → Process → Format → Return + +## Common Mistakes + +- Skipping validation of input data +`, } issues := lintStyle(s) assert.Empty(t, issues) } + +func TestLintStyle_DescriptionMissingTriggerPrefix(t *testing.T) { + s := &Skill{ + Name: "my-skill", + Description: "A skill that helps with task management", + Body: "## Instructions\n\nSome body content here.", + } + + issues := lintStyle(s) + hasTriggerHint := false + for _, i := range issues { + if i.Field == "description" && i.Severity == SeverityHint && strings.Contains(i.Message, "Use when") { + hasTriggerHint = true + } + } + assert.True(t, hasTriggerHint, "should hint that description should start with 'Use when'") +} + +func TestLintStyle_DescriptionWithTriggerPrefix(t *testing.T) { + tests := []struct { + name string + desc string + }{ + {"use when", "Use when deploying services to production"}, + {"use for", "Use for formatting code according to team standards"}, + {"use to", "Use to validate API responses before processing"}, + {"trigger when", "Trigger when a new pull request is opened"}, + {"apply when", "Apply when refactoring legacy code modules"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Skill{ + Name: "my-skill", + Description: tt.desc, + Body: "Short body.", + } + + issues := lintStyle(s) + for _, i := range issues { + if i.Field == "description" && strings.Contains(i.Message, "Use when") { + t.Errorf("should not hint about trigger prefix for description %q", tt.desc) + } + } + }) + } +} + +func TestLintStyle_BodyMissingRecommendedSections(t *testing.T) { + s := &Skill{ + Name: "my-skill", + Description: "Use when you need to do something specific", + Body: "## Instructions\n\nThis skill provides step-by-step guidance for completing tasks:\n\n- First, analyze the input carefully and validate the data\n- Then, process the data accordingly and format the results\n- Finally, return the formatted output to the user", + } + + issues := lintStyle(s) + hasSectionHint := false + for _, i := range issues { + if i.Field == "body" && i.Severity == SeverityHint && strings.Contains(i.Message, "missing recommended sections") { + hasSectionHint = true + assert.Contains(t, i.Message, "when to use") + assert.Contains(t, i.Message, "common mistakes") + } + } + assert.True(t, hasSectionHint, "should hint about missing recommended sections") +} + +func TestLintStyle_BodyWithAllRecommendedSections(t *testing.T) { + s := &Skill{ + Name: "my-skill", + Description: "Use when you need structured guidance", + Body: `## Overview + +- What this skill does + +## When to Use + +- Structured tasks + +## Core Pattern + +- Step-by-step approach + +## Quick Reference + +- Key points here + +## Common Mistakes + +- Forgetting validation +`, + } + + issues := lintStyle(s) + for _, i := range issues { + if i.Field == "body" && strings.Contains(i.Message, "missing recommended sections") { + t.Errorf("should not hint about missing sections when all are present: %s", i.Message) + } + } +} + +// TestScaffold_FreshSkillProducesNoLintHints guards the central UX promise of +// the writing-skills guidelines integration: a freshly scaffolded skill must +// be guideline-compliant out of the box. If DefaultBody, defaultDescription, +// or any lintStyle rule changes such that the scaffold is no longer +// compliant, this test fails and forces them back into sync. +func TestScaffold_FreshSkillProducesNoLintHints(t *testing.T) { + s := NewSkill("my-skill", "", "alice", "human", "") + + issues := lintStyle(s) + assert.Empty(t, issues, "freshly scaffolded skill must produce zero lint hints") +}