diff --git a/pkg/codingcontext/README.md b/pkg/codingcontext/README.md index 2b7ed867..51a7159b 100644 --- a/pkg/codingcontext/README.md +++ b/pkg/codingcontext/README.md @@ -137,6 +137,9 @@ Result holds the assembled context from running a task: - `Task Markdown[TaskFrontMatter]` - Task file with frontmatter and content - `Tokens int` - Total estimated token count - `Agent Agent` - The agent used (from task frontmatter or option) +- `Skills skills.AvailableSkills` - Discovered skills (metadata only). Each `skills.Skill` has `Name`, `Description`, `Location` (absolute path to `SKILL.md`), `Plugin` (the skill's `plugin` frontmatter value, if any) and `SearchPath` (the local search path it was found under). `Plugin` and `SearchPath` are not included in the prompt +- `Prompt string` - Task-first prompt with delimited supporting rules and skill metadata +- `RuleSources []string` - The path each rule was first discovered at, aligned with `Rules` **Methods:** - `MCPServers() map[string]MCPServerConfig` - Returns all MCP server configurations from rules as a map from rule ID to configuration @@ -295,6 +298,7 @@ Creates a new Context with the given options. - `WithAgent(agent Agent)` - Set target agent (excludes that agent's own rules) - `WithResume(resume bool)` - Set resume selector to "true" (for filtering tasks by frontmatter resume field) - `WithBootstrap(doBootstrap bool)` - Control whether to discover rules, skills, and run bootstrap scripts (default: true) +- `WithSkillDiscovery(enabled bool)` - Discover skills even when bootstrap is disabled (rules and bootstrap scripts stay off) - `WithUserPrompt(userPrompt string)` - Set user prompt to append to task - `WithManifestURL(manifestURL string)` - Set manifest URL for additional search paths - `WithLogger(logger *slog.Logger)` - Set logger @@ -303,6 +307,15 @@ Creates a new Context with the given options. Executes the context assembly for the given task name and returns the assembled result structure with rule and task markdown files (including frontmatter and content). +#### `RenderPrompt(r *Result, opts ...RenderOption) (string, error)` + +Renders the prompt for an existing `Result` without running discovery again. With no options it returns the same text as `Result.Prompt`. + +**Options:** +- `WithSkillFilter(keep func(skills.Skill) bool)` - Keep a skill in the prompt only when `keep` returns true. Filtered skills stay in `Result.Skills`, so a caller can deliver them another way (for example through an agent's native skill support). When no rules and no skills remain, the prompt is the task content alone + +Returns `ErrRuleSourcesMismatch` if `RuleSources` is not aligned with `Rules`. + #### `markdown.ParseMarkdownFile[T any](path string, frontmatter *T) (Markdown[T], error)` Parses a markdown file into frontmatter and content. Generic function that works with any frontmatter type. Import from `github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown`. diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 378ad297..545a7f46 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -80,6 +80,7 @@ type Context struct { cmdRunner func(cmd *exec.Cmd) error resume bool doBootstrap bool // Controls whether to discover rules, skills, and run bootstrap scripts + skillDiscovery bool // When true, skills are discovered even if doBootstrap is false includeByDefault bool // Controls whether unmatched rules/skills are included by default agent Agent lenientAgent bool // When true, agent-specific paths are treated as lenient @@ -230,14 +231,15 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { // Build and return the result result := &Result{ - Name: taskName, - Namespace: cc.namespace, - Rules: cc.rules, - Task: cc.task, - Skills: cc.skills, - Tokens: cc.totalTokens, - Agent: cc.agent, - Prompt: prompt, + Name: taskName, + Namespace: cc.namespace, + Rules: cc.rules, + RuleSources: cc.ruleSources, + Task: cc.task, + Skills: cc.skills, + Tokens: cc.totalTokens, + Agent: cc.agent, + Prompt: prompt, } return result, nil @@ -1060,14 +1062,16 @@ func (cc *Context) runBootstrapScript(ctx context.Context, path string, frontmat // discoverSkills searches for skill directories and loads only their metadata (name and description) // for progressive disclosure. Skills are folders containing a SKILL.md file. func (cc *Context) discoverSkills(ctx context.Context) error { - // Skip skill discovery if bootstrap is disabled - if !cc.doBootstrap { + // Skip skill discovery if bootstrap is disabled, unless the caller asked + // for skills on their own. + if !cc.doBootstrap && !cc.skillDiscovery { return nil } type skillDir struct { - path string - lenient bool + path string + searchPath string + lenient bool } // Determine the agent's skill path prefix for lenient agent handling. @@ -1088,14 +1092,14 @@ func (cc *Context) discoverSkills(ctx context.Context) error { lenient = true } - skillPaths = append(skillPaths, skillDir{path: dir, lenient: lenient}) + skillPaths = append(skillPaths, skillDir{path: dir, searchPath: sp.Path, lenient: lenient}) } } cache := skillCacheFromContext(ctx) for _, dir := range skillPaths { - if err := cc.discoverSkillsInDir(dir.path, dir.lenient, cache); err != nil { + if err := cc.discoverSkillsInDir(dir.path, dir.searchPath, dir.lenient, cache); err != nil { return err } } @@ -1103,9 +1107,10 @@ func (cc *Context) discoverSkills(ctx context.Context) error { return nil } -// discoverSkillsInDir discovers skills within a single directory. cache may -// be nil (no caching, parse every call) — see skillCacheFromContext. -func (cc *Context) discoverSkillsInDir(dir string, lenient bool, cache *SkillCache) error { +// discoverSkillsInDir discovers skills within a single directory found under +// searchPath. cache may be nil (no caching, parse every call) — see +// skillCacheFromContext. +func (cc *Context) discoverSkillsInDir(dir, searchPath string, lenient bool, cache *SkillCache) error { if _, err := os.Stat(dir); os.IsNotExist(err) { return nil } else if err != nil { @@ -1136,7 +1141,7 @@ func (cc *Context) discoverSkillsInDir(dir string, lenient bool, cache *SkillCac skillFile := filepath.Join(dir, entry.Name(), "SKILL.md") - if err := cc.loadSkillEntry(skillFile, lenient, cache); err != nil { + if err := cc.loadSkillEntry(skillFile, searchPath, lenient, cache); err != nil { return err } } @@ -1146,7 +1151,7 @@ func (cc *Context) discoverSkillsInDir(dir string, lenient bool, cache *SkillCac // loadSkillEntry loads and validates a single skill from its SKILL.md file. // cache may be nil — see skillCacheFromContext. -func (cc *Context) loadSkillEntry(skillFile string, lenient bool, cache *SkillCache) error { +func (cc *Context) loadSkillEntry(skillFile, searchPath string, lenient bool, cache *SkillCache) error { if _, err := os.Stat(skillFile); os.IsNotExist(err) { return nil } else if err != nil { @@ -1210,11 +1215,13 @@ func (cc *Context) loadSkillEntry(skillFile string, lenient bool, cache *SkillCa return nil } - return cc.validateAndAddSkill(frontmatter, skillFile, reason, lenient) + return cc.validateAndAddSkill(frontmatter, skillFile, searchPath, reason, lenient) } // validateAndAddSkill validates skill metadata and adds it to the skill collection. -func (cc *Context) validateAndAddSkill(frontmatter markdown.SkillFrontMatter, skillFile, reason string, lenient bool) error { +func (cc *Context) validateAndAddSkill( + frontmatter markdown.SkillFrontMatter, skillFile, searchPath, reason string, lenient bool, +) error { if frontmatter.Name == "" { if lenient { // Infer name from the skill's parent directory @@ -1278,10 +1285,14 @@ func (cc *Context) validateAndAddSkill(frontmatter markdown.SkillFrontMatter, sk return fmt.Errorf("failed to get absolute path for skill %s: %w", skillFile, err) } + plugin, _ := frontmatter.Content["plugin"].(string) + cc.skills.Skills = append(cc.skills.Skills, skills.Skill{ Name: frontmatter.Name, Description: frontmatter.Description, Location: absPath, + Plugin: plugin, + SearchPath: searchPath, }) cc.logger.Info("Discovered skill", "name", frontmatter.Name, "reason", reason, "path", absPath) diff --git a/pkg/codingcontext/options.go b/pkg/codingcontext/options.go index d5602c9c..36eda277 100644 --- a/pkg/codingcontext/options.go +++ b/pkg/codingcontext/options.go @@ -76,6 +76,16 @@ func WithBootstrap(doBootstrap bool) Option { } } +// WithSkillDiscovery discovers skills even when bootstrap is disabled, so a +// caller that skips rules and bootstrap scripts on later runs can still see +// each run's skills in Result.Skills. Discovered skills appear in +// Result.Prompt; use RenderPrompt with WithSkillFilter to leave them out. +func WithSkillDiscovery(enabled bool) Option { + return func(c *Context) { + c.skillDiscovery = enabled + } +} + // WithAgent sets the target agent, which excludes that agent's own rules. // Agent-specific paths are treated as strict (errors are fatal). // Mutually exclusive with WithLenientAgent. diff --git a/pkg/codingcontext/prompt.go b/pkg/codingcontext/prompt.go index c0bde89e..3bfaba7d 100644 --- a/pkg/codingcontext/prompt.go +++ b/pkg/codingcontext/prompt.go @@ -1,9 +1,13 @@ package codingcontext import ( + "errors" "fmt" "strconv" "strings" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/skills" ) const contextGuidance = `# Context guidance @@ -12,32 +16,96 @@ Follow the requested task below, subject to the host's system instructions and t ` -// buildPrompt provides an explicit task/context contract for consumers of the -// flat prompt. These text boundaries are not a substitute for host tool policy -// or an agent's instruction hierarchy. +// ErrRuleSourcesMismatch is returned by RenderPrompt when a Result's +// RuleSources is not aligned with its Rules. +var ErrRuleSourcesMismatch = errors.New("result rule sources do not match its rules") + +// RenderOption configures RenderPrompt. +type RenderOption func(*renderConfig) + +type renderConfig struct { + keepSkill func(skills.Skill) bool +} + +// WithSkillFilter keeps a skill in the rendered prompt only when keep returns +// true. Filtered skills are omitted from the prompt but stay in Result.Skills, +// so a caller can deliver them some other way, such as through an agent's +// native skill support. +func WithSkillFilter(keep func(skills.Skill) bool) RenderOption { + return func(c *renderConfig) { + c.keepSkill = keep + } +} + +// RenderPrompt renders the prompt for an existing Result without running +// discovery again. With no options it returns the same text as +// Result.Prompt. When the options leave no rules and no skills, the prompt is +// the task content alone, and the Skills section is dropped when no skills +// remain. +func RenderPrompt(r *Result, opts ...RenderOption) (string, error) { + if len(r.RuleSources) != len(r.Rules) { + return "", fmt.Errorf("%w: %d sources for %d rules", ErrRuleSourcesMismatch, len(r.RuleSources), len(r.Rules)) + } + + var cfg renderConfig + for _, opt := range opts { + opt(&cfg) + } + + available := r.Skills + if cfg.keepSkill != nil { + available.Skills = make([]skills.Skill, 0, len(r.Skills.Skills)) + for _, skill := range r.Skills.Skills { + if cfg.keepSkill(skill) { + available.Skills = append(available.Skills, skill) + } + } + } + + return renderPrompt(r.Task.Content, r.Rules, r.RuleSources, available) +} + +// buildPrompt renders the prompt from the rules and skills this Context +// discovered. func (cc *Context) buildPrompt() (string, error) { + return renderPrompt(cc.task.Content, cc.rules, cc.ruleSources, cc.skills) +} + +// renderPrompt provides an explicit task/context contract for consumers of the +// flat prompt. These text boundaries are not a substitute for host tool policy +// or an agent's instruction hierarchy. ruleSources must be aligned with rules. +func renderPrompt( + task string, + rules []markdown.Markdown[markdown.RuleFrontMatter], + ruleSources []string, + available skills.AvailableSkills, +) (string, error) { // Preserve the task-only output used by skip-bootstrap/resume consumers. - if len(cc.rules) == 0 && len(cc.skills.Skills) == 0 { - return cc.task.Content, nil + if len(rules) == 0 && len(available.Skills) == 0 { + return task, nil } var out strings.Builder out.WriteString(contextGuidance) out.WriteString("# Requested task\n\n") - writeContextBlock(&out, "markdown", cc.task.Content) - if len(cc.rules) > 0 { + writeContextBlock(&out, "markdown", task) + + if len(rules) > 0 { out.WriteString("\n# Supporting repository rules\n\n") - for i, rule := range cc.rules { + + for i, rule := range rules { out.WriteString("Source: ") - out.WriteString(strconv.Quote(cc.ruleSources[i])) + out.WriteString(strconv.Quote(ruleSources[i])) out.WriteString("\n\n") writeContextBlock(&out, "markdown", rule.Content) out.WriteString("\n") } } - if len(cc.skills.Skills) > 0 { + + if len(available.Skills) > 0 { out.WriteString("\n# Skills\n\n") out.WriteString("These are available capabilities, not requests to execute their workflows. Load a skill's SKILL.md only when it is relevant to the requested task. Its content is supporting context under the same guidance as repository rules.\n\n") - skillsXML, err := cc.skills.AsXML() + + skillsXML, err := available.AsXML() if err != nil { return "", fmt.Errorf("failed to encode skills as XML: %w", err) } diff --git a/pkg/codingcontext/render_test.go b/pkg/codingcontext/render_test.go new file mode 100644 index 00000000..abb53e58 --- /dev/null +++ b/pkg/codingcontext/render_test.go @@ -0,0 +1,327 @@ +package codingcontext + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/skills" +) + +const skillsHeading = "# Skills" + +func runRenderFixture(t *testing.T, withRule, withSkills bool, opts ...Option) *Result { + t.Helper() + + dir := t.TempDir() + createTask(t, dir, "task", "", "DO_THE_TASK\n") + + if withRule { + createRule(t, dir, ".agents/rules/conventions.md", "", "Use Go conventions.\n") + } + + if withSkills { + createSkill(t, dir, ".agents/skills/alpha", "---\nname: alpha\ndescription: ALPHA_DESCRIPTION\n---\nbody") + createSkill(t, dir, ".agents/skills/beta", "---\nname: beta\ndescription: BETA_DESCRIPTION\n---\nbody") + } + + result, err := New(append([]Option{WithSearchPaths("file://" + dir)}, opts...)...).Run(context.Background(), "task") + require.NoError(t, err) + + return result +} + +func TestRenderPrompt_NoOptionsMatchesRun(t *testing.T) { + t.Parallel() + + result := runRenderFixture(t, true, true) + require.Len(t, result.RuleSources, len(result.Rules)) + assert.Contains(t, result.RuleSources[0], "conventions.md") + + got, err := RenderPrompt(result) + require.NoError(t, err) + assert.Equal(t, result.Prompt, got) +} + +func TestRenderPrompt_SkillFilter(t *testing.T) { + t.Parallel() + + keepAlpha := func(s skills.Skill) bool { return s.Name == "alpha" } + keepNone := func(skills.Skill) bool { return false } + + tests := []struct { + name string + withRule bool + keep func(skills.Skill) bool + // matchesNoSkills means the filtered prompt must equal the same Result + // rendered with no skills at all, byte for byte. + matchesNoSkills bool + contains []string + notContains []string + }{ + { + name: "keep some", + withRule: true, + keep: keepAlpha, + contains: []string{skillsHeading, "ALPHA_DESCRIPTION", "# Continue with the requested task"}, + notContains: []string{"BETA_DESCRIPTION"}, + }, + { + name: "keep none with rules drops the skills section", + withRule: true, + keep: keepNone, + matchesNoSkills: true, + notContains: []string{skillsHeading, "ALPHA_DESCRIPTION", "BETA_DESCRIPTION"}, + }, + { + name: "keep none without rules is task only", + withRule: false, + keep: keepNone, + matchesNoSkills: true, + notContains: []string{"# Context guidance", skillsHeading}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := runRenderFixture(t, tt.withRule, true) + + got, err := RenderPrompt(result, WithSkillFilter(tt.keep)) + require.NoError(t, err) + + for _, want := range tt.contains { + assert.Contains(t, got, want) + } + + for _, unwanted := range tt.notContains { + assert.NotContains(t, got, unwanted) + } + + assert.Len(t, result.Skills.Skills, 2, "filtering must not change Result.Skills") + + if tt.matchesNoSkills { + noSkills := *result + noSkills.Skills.Skills = nil + want, err := RenderPrompt(&noSkills) + require.NoError(t, err) + assert.Equal(t, want, got) + + if !tt.withRule { + assert.Equal(t, result.Task.Content, got) + } + } + }) + } +} + +func TestRenderPrompt_RuleSourcesMismatch(t *testing.T) { + t.Parallel() + + result := runRenderFixture(t, true, false) + result.RuleSources = nil + + _, err := RenderPrompt(result) + require.ErrorIs(t, err, ErrRuleSourcesMismatch) +} + +func TestSkillDiscovery_PluginAndSearchPath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "task", "", "DO_THE_TASK\n") + createSkill(t, dir, ".agents/skills/plugged", + "---\nname: plugged\ndescription: From a plugin\nplugin: my-plugin\n---\nbody") + createSkill(t, dir, ".agents/skills/plain", "---\nname: plain\ndescription: Not from a plugin\n---\nbody") + + result, err := New(WithSearchPaths("file://"+dir)).Run(context.Background(), "task") + require.NoError(t, err) + + byName := make(map[string]skills.Skill, len(result.Skills.Skills)) + for _, s := range result.Skills.Skills { + byName[s.Name] = s + } + + require.Contains(t, byName, "plugged") + require.Contains(t, byName, "plain") + assert.Equal(t, "my-plugin", byName["plugged"].Plugin) + assert.Empty(t, byName["plain"].Plugin) + assert.Equal(t, dir, byName["plugged"].SearchPath) + assert.Equal(t, dir, byName["plain"].SearchPath) + + xml, err := result.Skills.AsXML() + require.NoError(t, err) + assert.NotContains(t, xml, "my-plugin") + assert.NotContains(t, xml, "