Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions pkg/codingcontext/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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`.
Expand Down
53 changes: 32 additions & 21 deletions pkg/codingcontext/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -1088,24 +1092,25 @@ 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
}
}

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 {
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions pkg/codingcontext/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
90 changes: 79 additions & 11 deletions pkg/codingcontext/prompt.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
Expand Down
Loading
Loading