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
150 changes: 150 additions & 0 deletions FEATURE_EXPLANATORY_LOGGING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Feature: Explanatory Logging

## Overview

The CLI now clearly explains WHY each rule, task, skill, and command was included or excluded when running a task. This makes it much easier to understand the context assembly process and debug selector issues.

## What Changed

### New Functionality

1. **Inclusion Explanations**: Every included item now logs the reason it was selected
2. **Exclusion Explanations**: Every excluded item now logs why it didn't match selectors
3. **Detailed Selector Matching**: Shows exactly which selector key-value pairs matched or didn't match

### Modified Files

- `pkg/codingcontext/selectors/selectors.go`: Refactored `MatchesIncludes()` to return `(bool, string)` with reason
- `pkg/codingcontext/context.go`: Enhanced logging throughout to explain inclusion/exclusion
- `pkg/codingcontext/selectors/selectors_test.go`: Added comprehensive table-driven tests

## Usage Examples

### Example 1: Including Rules with Matching Selectors

```bash
./coding-context -C examples -s language=go -s env=dev my-task
```

**Output:**
```
time=... level=INFO msg="Including task" name=my-task reason="task name matches 'my-task'" tokens=39
time=... level=INFO msg="Including rule file" path=.agents/rules/go-dev-rule.md reason="matched selectors: language=go, env=dev" tokens=44
time=... level=INFO msg="Including rule file" path=.agents/rules/generic-rule.md reason="no selectors specified (included by default)" tokens=45
time=... level=INFO msg="Discovered skill" name=go-testing reason="matched selectors: language=go" path=/path/to/skill/SKILL.md
```

### Example 2: Skipping Non-Matching Items

```bash
./coding-context -C examples -s language=python -s env=prod my-task
```

**Output:**
```
time=... level=INFO msg="Including task" name=my-task reason="task name matches 'my-task'" tokens=39
time=... level=INFO msg="Skipping file" path=.agents/rules/go-dev-rule.md reason="selectors did not match: language=go (expected language=python), env=dev (expected env=prod)"
time=... level=INFO msg="Including rule file" path=.agents/rules/python-prod-rule.md reason="matched selectors: language=python, env=prod" tokens=41
time=... level=INFO msg="Skipping skill" name=go-testing path=/path/to/skill/SKILL.md reason="selectors did not match: language=go (expected language=python)"
```

## Log Message Format

### Inclusion Messages

- **Tasks**: `Including task` with `name` and `reason="task name matches '<task-name>'"`
- **Rules**: `Including rule file` with `path`, `reason`, and `tokens`
- With selectors: `reason="matched selectors: key1=value1, key2=value2"`
- Without selectors: `reason="no selectors specified (included by default)"`
- **Skills**: `Discovered skill` with `name`, `path`, and `reason`
- With selectors: `reason="matched selectors: key1=value1, key2=value2"`
- Without selectors: `reason="no selectors specified (included by default)"`
- **Commands**: `Including command` with `name`, `path`, and `reason="referenced by slash command '/command-name'"`

### Exclusion Messages

- **Rules**: `Skipping file` with `path` and detailed mismatch explanation
- Single mismatch: `reason="selectors did not match: key=actual_value (expected key=expected_value)"`
- Multiple mismatches: `reason="selectors did not match: key1=actual1 (expected key1=expected1), key2=actual2 (expected key2=expected2)"`
- OR logic (multiple allowed values): `reason="selectors did not match: key=actual (expected key in [value1, value2, value3])"`

- **Skills**: `Skipping skill` with `name`, `path`, and detailed mismatch explanation (same format as rules)

## Implementation Details

### Refactored Method in `Selectors`

#### `MatchesIncludes(frontmatter BaseFrontMatter) (bool, string)`

Returns whether the frontmatter matches all include selectors, along with a human-readable reason explaining the result.

**Returns:**
- `bool`: true if all selectors match, false otherwise
- `string`: reason explaining why (matched selectors or mismatch details)

**Examples:**
```go
// Match case
match, reason := selectors.MatchesIncludes(frontmatter)
// match = true, reason = "matched selectors: language=go, env=dev"

// No match case
match, reason := selectors.MatchesIncludes(frontmatter)
// match = false, reason = "selectors did not match: language=python (expected language=go)"

// No selectors case
match, reason := selectors.MatchesIncludes(frontmatter)
// match = true, reason = "no selectors specified (included by default)"
```

### Selector Matching Logic

- **Missing keys**: If a selector key doesn't exist in frontmatter, it's allowed (not counted as a mismatch)
- **Matching values**: If a frontmatter value matches any selector value for that key, it matches (OR logic)
- **Non-matching values**: If a frontmatter value doesn't match any selector value for that key, it doesn't match

## Testing

### Test Coverage

- `TestSelectorMap_MatchesIncludes`: 19 test cases covering basic matching/non-matching scenarios
- `TestSelectorMap_MatchesIncludesReasons`: 9 test cases covering reason explanations with various scenarios
- All tests use table-driven test pattern (project standard)
- Tests cover: single selectors, multiple selectors, array selectors, OR logic, edge cases, both match and no-match cases

### Running Tests

```bash
# Test the selectors package
go test -v ./pkg/codingcontext/selectors/

# Test the main context package
go test -v ./pkg/codingcontext/

# Run all tests
go test -v ./...
```

## Benefits

1. **Debugging**: Instantly see why rules/skills aren't being included
2. **Transparency**: Understand exactly how selector matching works
3. **Configuration Validation**: Verify that your frontmatter and selectors are set up correctly
4. **Learning**: New users can understand the system by observing the logs
5. **Efficiency**: Single method call returns both match result and reason (no duplicate work)

## Backwards Compatibility

This feature is fully backwards compatible:
- No breaking changes to APIs or behavior
- Only adds additional logging information
- All existing tests pass
- Existing rule/task/skill files work without modification

## Future Enhancements

Potential improvements for future versions:
- Add a `--quiet` flag to suppress inclusion/exclusion logging
- Add a `--explain` flag to show even more detailed selector evaluation
- Color-code inclusion (green) vs exclusion (yellow) messages in terminal output
- Add summary statistics (e.g., "Included 5 rules, skipped 3 rules")
File renamed without changes.
35 changes: 25 additions & 10 deletions pkg/codingcontext/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func New(opts ...Option) *Context {
return c
}

type markdownVisitor func(path string) error
type markdownVisitor func(path string, fm *markdown.BaseFrontMatter) error

// findMarkdownFile searches for a markdown file by name in the given directories.
// Returns the path to the file if found, or an error if not found or multiple files match.
Expand Down Expand Up @@ -91,10 +91,15 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi
}

// Skip files that don't match selectors
if !cc.includes.MatchesIncludes(fm) {
matches, reason := cc.includes.MatchesIncludes(fm)
if !matches {
// Log why this file was skipped
if reason != "" {
cc.logger.Info("Skipping file", "path", path, "reason", reason)
}
return nil
}
return visitor(path)
return visitor(path, &fm)
})
if err != nil {
return fmt.Errorf("failed to walk directory %s: %w", dir, err)
Expand All @@ -110,7 +115,7 @@ func (cc *Context) findTask(taskName string) error {
cc.includes.SetValue("task_name", taskName)

taskFound := false
err := cc.visitMarkdownFiles(taskSearchPaths, func(path string) error {
err := cc.visitMarkdownFiles(taskSearchPaths, func(path string, _ *markdown.BaseFrontMatter) error {
baseName := filepath.Base(path)
ext := filepath.Ext(baseName)
if strings.TrimSuffix(baseName, ext) != taskName {
Expand Down Expand Up @@ -190,7 +195,7 @@ func (cc *Context) findTask(taskName string) error {
}
cc.totalTokens += cc.task.Tokens

cc.logger.Info("Including task", "tokens", cc.task.Tokens)
cc.logger.Info("Including task", "name", taskName, "reason", fmt.Sprintf("task name matches '%s'", taskName), "tokens", cc.task.Tokens)

return nil
})
Expand All @@ -211,7 +216,7 @@ func (cc *Context) findTask(taskName string) error {
// to allow commands to specify which rules they need.
func (cc *Context) findCommand(commandName string, params taskparser.Params) (string, error) {
var content *string
err := cc.visitMarkdownFiles(commandSearchPaths, func(path string) error {
err := cc.visitMarkdownFiles(commandSearchPaths, func(path string, _ *markdown.BaseFrontMatter) error {
baseName := filepath.Base(path)
ext := filepath.Ext(baseName)
if strings.TrimSuffix(baseName, ext) != commandName {
Expand Down Expand Up @@ -241,6 +246,8 @@ func (cc *Context) findCommand(commandName string, params taskparser.Params) (st
}
content = &processedContent

cc.logger.Info("Including command", "name", commandName, "reason", fmt.Sprintf("referenced by slash command '/%s'", commandName), "path", path)

return nil
})
if err != nil {
Expand Down Expand Up @@ -502,7 +509,7 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) err
return nil
}

err := cc.visitMarkdownFiles(rulePaths, func(path string) error {
err := cc.visitMarkdownFiles(rulePaths, func(path string, baseFm *markdown.BaseFrontMatter) error {
var frontmatter markdown.RuleFrontMatter
md, err := markdown.ParseMarkdownFile(path, &frontmatter)
if err != nil {
Expand All @@ -529,7 +536,9 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) err

cc.totalTokens += tokens

cc.logger.Info("Including rule file", "path", path, "tokens", tokens)
// Get match reason to explain why this rule was included
_, reason := cc.includes.MatchesIncludes(*baseFm)
cc.logger.Info("Including rule file", "path", path, "reason", reason, "tokens", tokens)

if err := cc.runBootstrapScript(ctx, path); err != nil {
return fmt.Errorf("failed to run bootstrap script: %w", err)
Expand Down Expand Up @@ -621,7 +630,12 @@ func (cc *Context) discoverSkills() error {
}

// Check if the skill matches the selectors first (before validation)
if !cc.includes.MatchesIncludes(frontmatter.BaseFrontMatter) {
matches, reason := cc.includes.MatchesIncludes(frontmatter.BaseFrontMatter)
if !matches {
// Log why this skill was skipped
if reason != "" {
cc.logger.Info("Skipping skill", "name", frontmatter.Name, "path", skillFile, "reason", reason)
}
continue
}

Expand Down Expand Up @@ -653,7 +667,8 @@ func (cc *Context) discoverSkills() error {
Location: absPath,
})

cc.logger.Info("Discovered skill", "name", frontmatter.Name, "path", absPath)
// Log with explanation of why skill was included
cc.logger.Info("Discovered skill", "name", frontmatter.Name, "reason", reason, "path", absPath)
}
}

Expand Down
47 changes: 41 additions & 6 deletions pkg/codingcontext/selectors/selectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,59 @@ func (s *Selectors) GetValue(key, value string) bool {
return innerMap[value]
}

// MatchesIncludes returns true if the frontmatter matches all include selectors.
// MatchesIncludes returns whether the frontmatter matches all include selectors,
// along with a human-readable reason explaining the result.
// If a key doesn't exist in frontmatter, it's allowed.
// Multiple values for the same key use OR logic (matches if frontmatter value is in the inner map).
// This enables combining CLI selectors (-s flag) with task frontmatter selectors:
// both are added to the same Selectors map, creating an OR condition for rules to match.
func (includes *Selectors) MatchesIncludes(frontmatter markdown.BaseFrontMatter) bool {
//
// Returns:
// - bool: true if all selectors match, false otherwise
// - string: reason explaining why (matched selectors or mismatch details)
func (includes *Selectors) MatchesIncludes(frontmatter markdown.BaseFrontMatter) (bool, string) {
if *includes == nil || len(*includes) == 0 {
return true, ""
}

var matchedSelectors []string
var noMatchReasons []string

for key, values := range *includes {
fmValue, exists := frontmatter.Content[key]
if !exists {
// If key doesn't exist in frontmatter, allow it
continue
}

// Check if frontmatter value matches any element in the inner map (OR logic)
fmStr := fmt.Sprint(fmValue)
if !values[fmStr] {
return false
if values[fmStr] {
// This selector matched
matchedSelectors = append(matchedSelectors, fmt.Sprintf("%s=%s", key, fmStr))
} else {
// This selector didn't match
var expectedValues []string
for val := range values {
expectedValues = append(expectedValues, val)
}
if len(expectedValues) == 1 {
noMatchReasons = append(noMatchReasons, fmt.Sprintf("%s=%s (expected %s=%s)", key, fmStr, key, expectedValues[0]))
} else {
noMatchReasons = append(noMatchReasons, fmt.Sprintf("%s=%s (expected %s in [%s])", key, fmStr, key, strings.Join(expectedValues, ", ")))
}
}
}
return true

// If any selector didn't match, return false with the mismatch reasons
if len(noMatchReasons) > 0 {
return false, fmt.Sprintf("selectors did not match: %s", strings.Join(noMatchReasons, ", "))
}

// All selectors matched
if len(matchedSelectors) > 0 {
return true, fmt.Sprintf("matched selectors: %s", strings.Join(matchedSelectors, ", "))
}

// No selectors specified
return true, "no selectors specified (included by default)"
Comment on lines +140 to +141
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This return statement is unreachable. The early return at lines 98-100 handles the case when there are no selectors (nil or empty map), so execution cannot reach line 141. Remove this unreachable code block.

Copilot uses AI. Check for mistakes.
}
Loading
Loading