From 1aa6d13fdf6769963ea738bb1cceac28b94a28dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:43:23 +0000 Subject: [PATCH 1/7] Initial plan From be7b4ac3debe530a7813b493a11b70a743b65164 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:50:43 +0000 Subject: [PATCH 2/7] Add Name field to all frontmatter with default to filename Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/context.go | 19 ++- pkg/codingcontext/context_test.go | 32 ++++ pkg/codingcontext/markdown/frontmatter.go | 5 + pkg/codingcontext/markdown/markdown.go | 37 +++++ pkg/codingcontext/markdown/markdown_test.go | 154 ++++++++++++++++++++ 5 files changed, 240 insertions(+), 7 deletions(-) diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 7d0655c..d12fdfa 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -105,25 +105,30 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi } // findTask searches for a task markdown file and returns it with parameters substituted +// Tasks can be found by: +// 1. The Name field in frontmatter (if specified) +// 2. The filename without extension (fallback) func (cc *Context) findTask(taskName string) error { // Add task name to includes so rules can be filtered cc.includes.SetValue("task_name", taskName) taskFound := false err := cc.visitMarkdownFiles(taskSearchPaths, func(path string) error { - baseName := filepath.Base(path) - ext := filepath.Ext(baseName) - if strings.TrimSuffix(baseName, ext) != taskName { - return nil - } - - taskFound = true + // Parse the file to access frontmatter var frontMatter markdown.TaskFrontMatter md, err := markdown.ParseMarkdownFile(path, &frontMatter) if err != nil { return fmt.Errorf("failed to parse task file %s: %w", path, err) } + // Check if this task matches by Name field or filename + // After parsing, Name is guaranteed to be set (either from frontmatter or defaulted to filename) + if frontMatter.Name != taskName { + return nil + } + + taskFound = true + // Extract selector labels from task frontmatter and add them to cc.includes. // This combines CLI selectors (from -s flag) with task selectors using OR logic: // rules match if their frontmatter value matches ANY selector value for a given key. diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index e11025d..89a6205 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -325,6 +325,38 @@ func TestContext_Run_Basic(t *testing.T) { wantErr: true, errContains: "task not found", }, + { + name: "task found by name field in frontmatter", + setup: func(t *testing.T, dir string) { + createTask(t, dir, "actual-filename", "name: custom-task-name\nagent: cursor", "Task content with custom name") + }, + taskName: "custom-task-name", + wantErr: false, + check: func(t *testing.T, result *Result) { + if !strings.Contains(result.Task.Content, "Task content with custom name") { + t.Errorf("expected task content, got %q", result.Task.Content) + } + if result.Task.FrontMatter.Name != "custom-task-name" { + t.Errorf("expected task name 'custom-task-name', got %q", result.Task.FrontMatter.Name) + } + }, + }, + { + name: "task name defaults to filename when not specified", + setup: func(t *testing.T, dir string) { + createTask(t, dir, "my-task-file", "agent: cursor", "Task content without name field") + }, + taskName: "my-task-file", + wantErr: false, + check: func(t *testing.T, result *Result) { + if !strings.Contains(result.Task.Content, "Task content without name field") { + t.Errorf("expected task content, got %q", result.Task.Content) + } + if result.Task.FrontMatter.Name != "my-task-file" { + t.Errorf("expected task name to default to 'my-task-file', got %q", result.Task.FrontMatter.Name) + } + }, + }, { name: "task with selectors sets includes", setup: func(t *testing.T, dir string) { diff --git a/pkg/codingcontext/markdown/frontmatter.go b/pkg/codingcontext/markdown/frontmatter.go index bb9f2ad..b23281c 100644 --- a/pkg/codingcontext/markdown/frontmatter.go +++ b/pkg/codingcontext/markdown/frontmatter.go @@ -9,6 +9,11 @@ import ( // BaseFrontMatter represents parsed YAML frontmatter from markdown files type BaseFrontMatter struct { + // Name is a standard identifier field for all file types + // For tasks: used for searching by name (defaults to filename without .md extension) + // For rules/commands: optional identifier (defaults to filename without extension) + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Content map[string]any `json:"-" yaml:",inline"` } diff --git a/pkg/codingcontext/markdown/markdown.go b/pkg/codingcontext/markdown/markdown.go index f9c2a7c..d92d540 100644 --- a/pkg/codingcontext/markdown/markdown.go +++ b/pkg/codingcontext/markdown/markdown.go @@ -5,6 +5,8 @@ import ( "bytes" "fmt" "os" + "path/filepath" + "strings" yaml "github.com/goccy/go-yaml" "github.com/kitproj/coding-context-cli/pkg/codingcontext/tokencount" @@ -78,9 +80,44 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) } } + // Default the Name field to filename without extension if not specified + setDefaultName(frontMatter, path) + return Markdown[T]{ FrontMatter: *frontMatter, Content: content.String(), Tokens: tokencount.EstimateTokens(content.String()), }, nil } + +// setDefaultName sets the Name field to the filename without extension if not already set +func setDefaultName(frontMatter any, path string) { + // Use type assertion to check if frontMatter has a Name field via BaseFrontMatter + switch fm := frontMatter.(type) { + case *TaskFrontMatter: + if fm.Name == "" { + fm.Name = getDefaultName(path) + } + case *RuleFrontMatter: + if fm.Name == "" { + fm.Name = getDefaultName(path) + } + case *CommandFrontMatter: + if fm.Name == "" { + fm.Name = getDefaultName(path) + } + case *SkillFrontMatter: + // Skills already have a required Name field, don't override + case *BaseFrontMatter: + if fm.Name == "" { + fm.Name = getDefaultName(path) + } + } +} + +// getDefaultName extracts the filename without extension +func getDefaultName(path string) string { + baseName := filepath.Base(path) + ext := filepath.Ext(baseName) + return strings.TrimSuffix(baseName, ext) +} diff --git a/pkg/codingcontext/markdown/markdown_test.go b/pkg/codingcontext/markdown/markdown_test.go index 86f4db1..7d8136f 100644 --- a/pkg/codingcontext/markdown/markdown_test.go +++ b/pkg/codingcontext/markdown/markdown_test.go @@ -272,3 +272,157 @@ This task has no frontmatter. }) } } + +// TestParseMarkdownFile_NameFieldDefaulting tests that the Name field is defaulted to filename +func TestParseMarkdownFile_NameFieldDefaulting(t *testing.T) { + tests := []struct { + name string + filename string + content string + wantName string + frontmatterType string // "task", "rule", "command", "base" + }{ + { + name: "task with explicit name field", + filename: "my-task.md", + content: `--- +name: custom-task-name +agent: cursor +--- +# My Task Content +`, + wantName: "custom-task-name", + frontmatterType: "task", + }, + { + name: "task without name field - defaults to filename", + filename: "fix-bug.md", + content: `--- +agent: cursor +--- +# Fix Bug Task +`, + wantName: "fix-bug", + frontmatterType: "task", + }, + { + name: "task without frontmatter - defaults to filename", + filename: "deploy-app.md", + content: `# Deploy Application + +This task has no frontmatter. +`, + wantName: "deploy-app", + frontmatterType: "task", + }, + { + name: "rule with explicit name field", + filename: "go-style.md", + content: `--- +name: go-coding-standards +languages: + - go +--- +# Go Coding Standards +`, + wantName: "go-coding-standards", + frontmatterType: "rule", + }, + { + name: "rule without name field - defaults to filename", + filename: "testing-guidelines.md", + content: `--- +languages: + - go +--- +# Testing Guidelines +`, + wantName: "testing-guidelines", + frontmatterType: "rule", + }, + { + name: "command with explicit name field", + filename: "setup-db.md", + content: `--- +name: database-setup +--- +# Setup Database +`, + wantName: "database-setup", + frontmatterType: "command", + }, + { + name: "command without name field - defaults to filename", + filename: "run-tests.md", + content: `--- +expand: true +--- +# Run Tests +`, + wantName: "run-tests", + frontmatterType: "command", + }, + { + name: "file with .mdc extension", + filename: "my-rule.mdc", + content: `--- +languages: + - go +--- +# My Rule +`, + wantName: "my-rule", + frontmatterType: "rule", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary file with the specified filename + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, tt.filename) + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + // Parse based on frontmatter type + var gotName string + switch tt.frontmatterType { + case "task": + var fm TaskFrontMatter + md, err := ParseMarkdownFile(tmpFile, &fm) + if err != nil { + t.Fatalf("ParseMarkdownFile() error = %v", err) + } + gotName = md.FrontMatter.Name + case "rule": + var fm RuleFrontMatter + md, err := ParseMarkdownFile(tmpFile, &fm) + if err != nil { + t.Fatalf("ParseMarkdownFile() error = %v", err) + } + gotName = md.FrontMatter.Name + case "command": + var fm CommandFrontMatter + md, err := ParseMarkdownFile(tmpFile, &fm) + if err != nil { + t.Fatalf("ParseMarkdownFile() error = %v", err) + } + gotName = md.FrontMatter.Name + case "base": + var fm BaseFrontMatter + md, err := ParseMarkdownFile(tmpFile, &fm) + if err != nil { + t.Fatalf("ParseMarkdownFile() error = %v", err) + } + gotName = md.FrontMatter.Name + default: + t.Fatalf("unknown frontmatter type: %s", tt.frontmatterType) + } + + if gotName != tt.wantName { + t.Errorf("Name = %q, want %q", gotName, tt.wantName) + } + }) + } +} From a469e029c0b025dfcd091f63a4d0a3c535dce4e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:55:05 +0000 Subject: [PATCH 3/7] Improve comment about SkillFrontMatter Name field shadowing Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/markdown/markdown.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/codingcontext/markdown/markdown.go b/pkg/codingcontext/markdown/markdown.go index d92d540..9b7d847 100644 --- a/pkg/codingcontext/markdown/markdown.go +++ b/pkg/codingcontext/markdown/markdown.go @@ -107,7 +107,8 @@ func setDefaultName(frontMatter any, path string) { fm.Name = getDefaultName(path) } case *SkillFrontMatter: - // Skills already have a required Name field, don't override + // Skills already have a required Name field (shadows BaseFrontMatter.Name), don't override + // The skill Name field is validated separately in skill discovery case *BaseFrontMatter: if fm.Name == "" { fm.Name = getDefaultName(path) From 18a01151c5e275ca5924fad7140d50f645c8aa57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:09:21 +0000 Subject: [PATCH 4/7] Replace Name field with ID field using URN format Changed from Name to ID field as requested: - ID field in URN format: urn:TYPE:basename - Defaults to URN based on file type (task, rule, command, skill) - Tasks found by ID basename (e.g., "fix-bug" matches "urn:task:fix-bug") - Backward compatible with custom IDs - All tests updated and passing Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/context.go | 30 +++++++-- pkg/codingcontext/context_test.go | 22 +++--- pkg/codingcontext/markdown/frontmatter.go | 8 +-- pkg/codingcontext/markdown/markdown.go | 39 ++++++----- pkg/codingcontext/markdown/markdown_test.go | 74 +++++++++++---------- 5 files changed, 101 insertions(+), 72 deletions(-) diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index d12fdfa..6b8703f 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -106,13 +106,15 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi // findTask searches for a task markdown file and returns it with parameters substituted // Tasks can be found by: -// 1. The Name field in frontmatter (if specified) -// 2. The filename without extension (fallback) +// 1. ID field matching "urn:task:taskName" or just "taskName" (for backward compatibility) +// 2. Filename without extension matching taskName (fallback for files without ID) func (cc *Context) findTask(taskName string) error { // Add task name to includes so rules can be filtered cc.includes.SetValue("task_name", taskName) taskFound := false + expectedURN := fmt.Sprintf("urn:task:%s", taskName) + err := cc.visitMarkdownFiles(taskSearchPaths, func(path string) error { // Parse the file to access frontmatter var frontMatter markdown.TaskFrontMatter @@ -121,9 +123,27 @@ func (cc *Context) findTask(taskName string) error { return fmt.Errorf("failed to parse task file %s: %w", path, err) } - // Check if this task matches by Name field or filename - // After parsing, Name is guaranteed to be set (either from frontmatter or defaulted to filename) - if frontMatter.Name != taskName { + // Check if this task matches by ID field + // ID can be: + // - Full URN format: "urn:task:fix-bug" + // - Just the name: "fix-bug" (matches the basename part of the URN) + // After parsing, ID is guaranteed to be set (either from frontmatter or defaulted to URN) + matched := false + if frontMatter.ID == expectedURN { + // Exact URN match + matched = true + } else if frontMatter.ID == taskName { + // Plain name match (for custom IDs without urn: prefix) + matched = true + } else if strings.HasPrefix(frontMatter.ID, "urn:task:") { + // Extract basename from URN and compare + idBasename := strings.TrimPrefix(frontMatter.ID, "urn:task:") + if idBasename == taskName { + matched = true + } + } + + if !matched { return nil } diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index 89a6205..d636770 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -326,34 +326,34 @@ func TestContext_Run_Basic(t *testing.T) { errContains: "task not found", }, { - name: "task found by name field in frontmatter", + name: "task found by custom ID in frontmatter", setup: func(t *testing.T, dir string) { - createTask(t, dir, "actual-filename", "name: custom-task-name\nagent: cursor", "Task content with custom name") + createTask(t, dir, "actual-filename", "id: urn:task:custom-task-id\nagent: cursor", "Task content with custom ID") }, - taskName: "custom-task-name", + taskName: "custom-task-id", wantErr: false, check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Task content with custom name") { + if !strings.Contains(result.Task.Content, "Task content with custom ID") { t.Errorf("expected task content, got %q", result.Task.Content) } - if result.Task.FrontMatter.Name != "custom-task-name" { - t.Errorf("expected task name 'custom-task-name', got %q", result.Task.FrontMatter.Name) + if result.Task.FrontMatter.ID != "urn:task:custom-task-id" { + t.Errorf("expected task ID 'urn:task:custom-task-id', got %q", result.Task.FrontMatter.ID) } }, }, { - name: "task name defaults to filename when not specified", + name: "task ID defaults to URN when not specified", setup: func(t *testing.T, dir string) { - createTask(t, dir, "my-task-file", "agent: cursor", "Task content without name field") + createTask(t, dir, "my-task-file", "agent: cursor", "Task content without ID field") }, taskName: "my-task-file", wantErr: false, check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Task content without name field") { + if !strings.Contains(result.Task.Content, "Task content without ID field") { t.Errorf("expected task content, got %q", result.Task.Content) } - if result.Task.FrontMatter.Name != "my-task-file" { - t.Errorf("expected task name to default to 'my-task-file', got %q", result.Task.FrontMatter.Name) + if result.Task.FrontMatter.ID != "urn:task:my-task-file" { + t.Errorf("expected task ID to default to 'urn:task:my-task-file', got %q", result.Task.FrontMatter.ID) } }, }, diff --git a/pkg/codingcontext/markdown/frontmatter.go b/pkg/codingcontext/markdown/frontmatter.go index b23281c..b8238e5 100644 --- a/pkg/codingcontext/markdown/frontmatter.go +++ b/pkg/codingcontext/markdown/frontmatter.go @@ -9,10 +9,10 @@ import ( // BaseFrontMatter represents parsed YAML frontmatter from markdown files type BaseFrontMatter struct { - // Name is a standard identifier field for all file types - // For tasks: used for searching by name (defaults to filename without .md extension) - // For rules/commands: optional identifier (defaults to filename without extension) - Name string `yaml:"name,omitempty" json:"name,omitempty"` + // ID is a standard identifier field for all file types in URN format + // Format: urn:TYPE:basename (e.g., urn:task:fix-bug, urn:rule:go-style) + // Defaults to URN based on file type and filename without extension if not specified + ID string `yaml:"id,omitempty" json:"id,omitempty"` Content map[string]any `json:"-" yaml:",inline"` } diff --git a/pkg/codingcontext/markdown/markdown.go b/pkg/codingcontext/markdown/markdown.go index 9b7d847..30f1660 100644 --- a/pkg/codingcontext/markdown/markdown.go +++ b/pkg/codingcontext/markdown/markdown.go @@ -80,8 +80,8 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) } } - // Default the Name field to filename without extension if not specified - setDefaultName(frontMatter, path) + // Default the ID field to URN format based on file type and filename if not specified + setDefaultID(frontMatter, path) return Markdown[T]{ FrontMatter: *frontMatter, @@ -90,34 +90,39 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) }, nil } -// setDefaultName sets the Name field to the filename without extension if not already set -func setDefaultName(frontMatter any, path string) { - // Use type assertion to check if frontMatter has a Name field via BaseFrontMatter +// setDefaultID sets the ID field to URN format if not already set +// Format: urn:TYPE:basename where TYPE is task, rule, command, etc. +func setDefaultID(frontMatter any, path string) { + basename := getBasename(path) + switch fm := frontMatter.(type) { case *TaskFrontMatter: - if fm.Name == "" { - fm.Name = getDefaultName(path) + if fm.ID == "" { + fm.ID = fmt.Sprintf("urn:task:%s", basename) } case *RuleFrontMatter: - if fm.Name == "" { - fm.Name = getDefaultName(path) + if fm.ID == "" { + fm.ID = fmt.Sprintf("urn:rule:%s", basename) } case *CommandFrontMatter: - if fm.Name == "" { - fm.Name = getDefaultName(path) + if fm.ID == "" { + fm.ID = fmt.Sprintf("urn:command:%s", basename) } case *SkillFrontMatter: - // Skills already have a required Name field (shadows BaseFrontMatter.Name), don't override - // The skill Name field is validated separately in skill discovery + // Skills have their own Name field, but we still set ID for consistency + if fm.ID == "" { + fm.ID = fmt.Sprintf("urn:skill:%s", basename) + } case *BaseFrontMatter: - if fm.Name == "" { - fm.Name = getDefaultName(path) + if fm.ID == "" { + // For generic BaseFrontMatter, use a generic type + fm.ID = fmt.Sprintf("urn:file:%s", basename) } } } -// getDefaultName extracts the filename without extension -func getDefaultName(path string) string { +// getBasename extracts the filename without extension +func getBasename(path string) string { baseName := filepath.Base(path) ext := filepath.Ext(baseName) return strings.TrimSuffix(baseName, ext) diff --git a/pkg/codingcontext/markdown/markdown_test.go b/pkg/codingcontext/markdown/markdown_test.go index 7d8136f..ee61991 100644 --- a/pkg/codingcontext/markdown/markdown_test.go +++ b/pkg/codingcontext/markdown/markdown_test.go @@ -273,63 +273,63 @@ This task has no frontmatter. } } -// TestParseMarkdownFile_NameFieldDefaulting tests that the Name field is defaulted to filename -func TestParseMarkdownFile_NameFieldDefaulting(t *testing.T) { +// TestParseMarkdownFile_IDFieldDefaulting tests that the ID field is defaulted to URN format +func TestParseMarkdownFile_IDFieldDefaulting(t *testing.T) { tests := []struct { name string filename string content string - wantName string - frontmatterType string // "task", "rule", "command", "base" + wantID string + frontmatterType string // "task", "rule", "command" }{ { - name: "task with explicit name field", + name: "task with explicit ID field", filename: "my-task.md", content: `--- -name: custom-task-name +id: urn:task:custom-task-id agent: cursor --- # My Task Content `, - wantName: "custom-task-name", + wantID: "urn:task:custom-task-id", frontmatterType: "task", }, { - name: "task without name field - defaults to filename", + name: "task without ID field - defaults to URN", filename: "fix-bug.md", content: `--- agent: cursor --- # Fix Bug Task `, - wantName: "fix-bug", + wantID: "urn:task:fix-bug", frontmatterType: "task", }, { - name: "task without frontmatter - defaults to filename", + name: "task without frontmatter - defaults to URN", filename: "deploy-app.md", content: `# Deploy Application This task has no frontmatter. `, - wantName: "deploy-app", + wantID: "urn:task:deploy-app", frontmatterType: "task", }, { - name: "rule with explicit name field", + name: "rule with explicit ID field", filename: "go-style.md", content: `--- -name: go-coding-standards +id: urn:rule:go-coding-standards languages: - go --- # Go Coding Standards `, - wantName: "go-coding-standards", + wantID: "urn:rule:go-coding-standards", frontmatterType: "rule", }, { - name: "rule without name field - defaults to filename", + name: "rule without ID field - defaults to URN", filename: "testing-guidelines.md", content: `--- languages: @@ -337,29 +337,29 @@ languages: --- # Testing Guidelines `, - wantName: "testing-guidelines", + wantID: "urn:rule:testing-guidelines", frontmatterType: "rule", }, { - name: "command with explicit name field", + name: "command with explicit ID field", filename: "setup-db.md", content: `--- -name: database-setup +id: urn:command:database-setup --- # Setup Database `, - wantName: "database-setup", + wantID: "urn:command:database-setup", frontmatterType: "command", }, { - name: "command without name field - defaults to filename", + name: "command without ID field - defaults to URN", filename: "run-tests.md", content: `--- expand: true --- # Run Tests `, - wantName: "run-tests", + wantID: "urn:command:run-tests", frontmatterType: "command", }, { @@ -371,9 +371,20 @@ languages: --- # My Rule `, - wantName: "my-rule", + wantID: "urn:rule:my-rule", frontmatterType: "rule", }, + { + name: "task with custom non-URN ID", + filename: "my-task.md", + content: `--- +id: custom-id-without-urn +--- +# My Task +`, + wantID: "custom-id-without-urn", + frontmatterType: "task", + }, } for _, tt := range tests { @@ -386,7 +397,7 @@ languages: } // Parse based on frontmatter type - var gotName string + var gotID string switch tt.frontmatterType { case "task": var fm TaskFrontMatter @@ -394,34 +405,27 @@ languages: if err != nil { t.Fatalf("ParseMarkdownFile() error = %v", err) } - gotName = md.FrontMatter.Name + gotID = md.FrontMatter.ID case "rule": var fm RuleFrontMatter md, err := ParseMarkdownFile(tmpFile, &fm) if err != nil { t.Fatalf("ParseMarkdownFile() error = %v", err) } - gotName = md.FrontMatter.Name + gotID = md.FrontMatter.ID case "command": var fm CommandFrontMatter md, err := ParseMarkdownFile(tmpFile, &fm) if err != nil { t.Fatalf("ParseMarkdownFile() error = %v", err) } - gotName = md.FrontMatter.Name - case "base": - var fm BaseFrontMatter - md, err := ParseMarkdownFile(tmpFile, &fm) - if err != nil { - t.Fatalf("ParseMarkdownFile() error = %v", err) - } - gotName = md.FrontMatter.Name + gotID = md.FrontMatter.ID default: t.Fatalf("unknown frontmatter type: %s", tt.frontmatterType) } - if gotName != tt.wantName { - t.Errorf("Name = %q, want %q", gotName, tt.wantName) + if gotID != tt.wantID { + t.Errorf("ID = %q, want %q", gotID, tt.wantID) } }) } From 5cf924729883fafb6cec9644021dc4da39c61f83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:25:42 +0000 Subject: [PATCH 5/7] Remove URN prefix from ID field format Changed ID format from urn:TYPE:basename to TYPE:basename: - task:fix-bug instead of urn:task:fix-bug - rule:go-style instead of urn:rule:go-style - command:setup instead of urn:command:setup - Updated all tests and documentation - All tests passing Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/context.go | 22 ++++++------ pkg/codingcontext/context_test.go | 12 +++---- pkg/codingcontext/markdown/frontmatter.go | 6 ++-- pkg/codingcontext/markdown/markdown.go | 14 ++++---- pkg/codingcontext/markdown/markdown_test.go | 38 ++++++++++----------- 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 6b8703f..d968247 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -106,14 +106,14 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi // findTask searches for a task markdown file and returns it with parameters substituted // Tasks can be found by: -// 1. ID field matching "urn:task:taskName" or just "taskName" (for backward compatibility) +// 1. ID field matching "task:taskName" or just "taskName" (for backward compatibility) // 2. Filename without extension matching taskName (fallback for files without ID) func (cc *Context) findTask(taskName string) error { // Add task name to includes so rules can be filtered cc.includes.SetValue("task_name", taskName) taskFound := false - expectedURN := fmt.Sprintf("urn:task:%s", taskName) + expectedID := fmt.Sprintf("task:%s", taskName) err := cc.visitMarkdownFiles(taskSearchPaths, func(path string) error { // Parse the file to access frontmatter @@ -125,19 +125,19 @@ func (cc *Context) findTask(taskName string) error { // Check if this task matches by ID field // ID can be: - // - Full URN format: "urn:task:fix-bug" - // - Just the name: "fix-bug" (matches the basename part of the URN) - // After parsing, ID is guaranteed to be set (either from frontmatter or defaulted to URN) + // - Full format: "task:fix-bug" + // - Just the name: "fix-bug" (matches the basename part) + // After parsing, ID is guaranteed to be set (either from frontmatter or defaulted) matched := false - if frontMatter.ID == expectedURN { - // Exact URN match + if frontMatter.ID == expectedID { + // Exact ID match matched = true } else if frontMatter.ID == taskName { - // Plain name match (for custom IDs without urn: prefix) + // Plain name match (for custom IDs without task: prefix) matched = true - } else if strings.HasPrefix(frontMatter.ID, "urn:task:") { - // Extract basename from URN and compare - idBasename := strings.TrimPrefix(frontMatter.ID, "urn:task:") + } else if strings.HasPrefix(frontMatter.ID, "task:") { + // Extract basename from ID and compare + idBasename := strings.TrimPrefix(frontMatter.ID, "task:") if idBasename == taskName { matched = true } diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index d636770..47da2e2 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -328,7 +328,7 @@ func TestContext_Run_Basic(t *testing.T) { { name: "task found by custom ID in frontmatter", setup: func(t *testing.T, dir string) { - createTask(t, dir, "actual-filename", "id: urn:task:custom-task-id\nagent: cursor", "Task content with custom ID") + createTask(t, dir, "actual-filename", "id: task:custom-task-id\nagent: cursor", "Task content with custom ID") }, taskName: "custom-task-id", wantErr: false, @@ -336,13 +336,13 @@ func TestContext_Run_Basic(t *testing.T) { if !strings.Contains(result.Task.Content, "Task content with custom ID") { t.Errorf("expected task content, got %q", result.Task.Content) } - if result.Task.FrontMatter.ID != "urn:task:custom-task-id" { - t.Errorf("expected task ID 'urn:task:custom-task-id', got %q", result.Task.FrontMatter.ID) + if result.Task.FrontMatter.ID != "task:custom-task-id" { + t.Errorf("expected task ID 'task:custom-task-id', got %q", result.Task.FrontMatter.ID) } }, }, { - name: "task ID defaults to URN when not specified", + name: "task ID defaults to TYPE:basename when not specified", setup: func(t *testing.T, dir string) { createTask(t, dir, "my-task-file", "agent: cursor", "Task content without ID field") }, @@ -352,8 +352,8 @@ func TestContext_Run_Basic(t *testing.T) { if !strings.Contains(result.Task.Content, "Task content without ID field") { t.Errorf("expected task content, got %q", result.Task.Content) } - if result.Task.FrontMatter.ID != "urn:task:my-task-file" { - t.Errorf("expected task ID to default to 'urn:task:my-task-file', got %q", result.Task.FrontMatter.ID) + if result.Task.FrontMatter.ID != "task:my-task-file" { + t.Errorf("expected task ID to default to 'task:my-task-file', got %q", result.Task.FrontMatter.ID) } }, }, diff --git a/pkg/codingcontext/markdown/frontmatter.go b/pkg/codingcontext/markdown/frontmatter.go index b8238e5..44a18a5 100644 --- a/pkg/codingcontext/markdown/frontmatter.go +++ b/pkg/codingcontext/markdown/frontmatter.go @@ -9,9 +9,9 @@ import ( // BaseFrontMatter represents parsed YAML frontmatter from markdown files type BaseFrontMatter struct { - // ID is a standard identifier field for all file types in URN format - // Format: urn:TYPE:basename (e.g., urn:task:fix-bug, urn:rule:go-style) - // Defaults to URN based on file type and filename without extension if not specified + // ID is a standard identifier field for all file types + // Format: TYPE:basename (e.g., task:fix-bug, rule:go-style) + // Defaults to TYPE:basename based on file type and filename without extension if not specified ID string `yaml:"id,omitempty" json:"id,omitempty"` Content map[string]any `json:"-" yaml:",inline"` diff --git a/pkg/codingcontext/markdown/markdown.go b/pkg/codingcontext/markdown/markdown.go index 30f1660..7a9ba32 100644 --- a/pkg/codingcontext/markdown/markdown.go +++ b/pkg/codingcontext/markdown/markdown.go @@ -90,33 +90,33 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) }, nil } -// setDefaultID sets the ID field to URN format if not already set -// Format: urn:TYPE:basename where TYPE is task, rule, command, etc. +// setDefaultID sets the ID field to TYPE:basename format if not already set +// Format: TYPE:basename where TYPE is task, rule, command, etc. func setDefaultID(frontMatter any, path string) { basename := getBasename(path) switch fm := frontMatter.(type) { case *TaskFrontMatter: if fm.ID == "" { - fm.ID = fmt.Sprintf("urn:task:%s", basename) + fm.ID = fmt.Sprintf("task:%s", basename) } case *RuleFrontMatter: if fm.ID == "" { - fm.ID = fmt.Sprintf("urn:rule:%s", basename) + fm.ID = fmt.Sprintf("rule:%s", basename) } case *CommandFrontMatter: if fm.ID == "" { - fm.ID = fmt.Sprintf("urn:command:%s", basename) + fm.ID = fmt.Sprintf("command:%s", basename) } case *SkillFrontMatter: // Skills have their own Name field, but we still set ID for consistency if fm.ID == "" { - fm.ID = fmt.Sprintf("urn:skill:%s", basename) + fm.ID = fmt.Sprintf("skill:%s", basename) } case *BaseFrontMatter: if fm.ID == "" { // For generic BaseFrontMatter, use a generic type - fm.ID = fmt.Sprintf("urn:file:%s", basename) + fm.ID = fmt.Sprintf("file:%s", basename) } } } diff --git a/pkg/codingcontext/markdown/markdown_test.go b/pkg/codingcontext/markdown/markdown_test.go index ee61991..63a2eec 100644 --- a/pkg/codingcontext/markdown/markdown_test.go +++ b/pkg/codingcontext/markdown/markdown_test.go @@ -273,7 +273,7 @@ This task has no frontmatter. } } -// TestParseMarkdownFile_IDFieldDefaulting tests that the ID field is defaulted to URN format +// TestParseMarkdownFile_IDFieldDefaulting tests that the ID field is defaulted to TYPE:basename format func TestParseMarkdownFile_IDFieldDefaulting(t *testing.T) { tests := []struct { name string @@ -286,50 +286,50 @@ func TestParseMarkdownFile_IDFieldDefaulting(t *testing.T) { name: "task with explicit ID field", filename: "my-task.md", content: `--- -id: urn:task:custom-task-id +id: task:custom-task-id agent: cursor --- # My Task Content `, - wantID: "urn:task:custom-task-id", + wantID: "task:custom-task-id", frontmatterType: "task", }, { - name: "task without ID field - defaults to URN", + name: "task without ID field - defaults to TYPE:basename", filename: "fix-bug.md", content: `--- agent: cursor --- # Fix Bug Task `, - wantID: "urn:task:fix-bug", + wantID: "task:fix-bug", frontmatterType: "task", }, { - name: "task without frontmatter - defaults to URN", + name: "task without frontmatter - defaults to TYPE:basename", filename: "deploy-app.md", content: `# Deploy Application This task has no frontmatter. `, - wantID: "urn:task:deploy-app", + wantID: "task:deploy-app", frontmatterType: "task", }, { name: "rule with explicit ID field", filename: "go-style.md", content: `--- -id: urn:rule:go-coding-standards +id: rule:go-coding-standards languages: - go --- # Go Coding Standards `, - wantID: "urn:rule:go-coding-standards", + wantID: "rule:go-coding-standards", frontmatterType: "rule", }, { - name: "rule without ID field - defaults to URN", + name: "rule without ID field - defaults to TYPE:basename", filename: "testing-guidelines.md", content: `--- languages: @@ -337,29 +337,29 @@ languages: --- # Testing Guidelines `, - wantID: "urn:rule:testing-guidelines", + wantID: "rule:testing-guidelines", frontmatterType: "rule", }, { name: "command with explicit ID field", filename: "setup-db.md", content: `--- -id: urn:command:database-setup +id: command:database-setup --- # Setup Database `, - wantID: "urn:command:database-setup", + wantID: "command:database-setup", frontmatterType: "command", }, { - name: "command without ID field - defaults to URN", + name: "command without ID field - defaults to TYPE:basename", filename: "run-tests.md", content: `--- expand: true --- # Run Tests `, - wantID: "urn:command:run-tests", + wantID: "command:run-tests", frontmatterType: "command", }, { @@ -371,18 +371,18 @@ languages: --- # My Rule `, - wantID: "urn:rule:my-rule", + wantID: "rule:my-rule", frontmatterType: "rule", }, { - name: "task with custom non-URN ID", + name: "task with custom ID without prefix", filename: "my-task.md", content: `--- -id: custom-id-without-urn +id: custom-id-without-prefix --- # My Task `, - wantID: "custom-id-without-urn", + wantID: "custom-id-without-prefix", frontmatterType: "task", }, } From 7bf90017731104b3fcb9fa8087822ed2847f3aa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:33:18 +0000 Subject: [PATCH 6/7] Change ID format to path-like with plural types Changed ID format to be more path-like: - tasks/fix-bug instead of task:fix-bug - rules/go-style instead of rule:go-style - commands/setup instead of command:setup - skills/test instead of skill:test - Using plural types (tasks, rules, commands, skills) - Updated all tests and documentation - All tests passing Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/context.go | 12 ++++---- pkg/codingcontext/context_test.go | 12 ++++---- pkg/codingcontext/markdown/frontmatter.go | 4 +-- pkg/codingcontext/markdown/markdown.go | 14 ++++----- pkg/codingcontext/markdown/markdown_test.go | 32 ++++++++++----------- 5 files changed, 37 insertions(+), 37 deletions(-) diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index d968247..0f192cd 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -106,14 +106,14 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi // findTask searches for a task markdown file and returns it with parameters substituted // Tasks can be found by: -// 1. ID field matching "task:taskName" or just "taskName" (for backward compatibility) +// 1. ID field matching "tasks/taskName" or just "taskName" (for backward compatibility) // 2. Filename without extension matching taskName (fallback for files without ID) func (cc *Context) findTask(taskName string) error { // Add task name to includes so rules can be filtered cc.includes.SetValue("task_name", taskName) taskFound := false - expectedID := fmt.Sprintf("task:%s", taskName) + expectedID := fmt.Sprintf("tasks/%s", taskName) err := cc.visitMarkdownFiles(taskSearchPaths, func(path string) error { // Parse the file to access frontmatter @@ -125,7 +125,7 @@ func (cc *Context) findTask(taskName string) error { // Check if this task matches by ID field // ID can be: - // - Full format: "task:fix-bug" + // - Full format: "tasks/fix-bug" // - Just the name: "fix-bug" (matches the basename part) // After parsing, ID is guaranteed to be set (either from frontmatter or defaulted) matched := false @@ -133,11 +133,11 @@ func (cc *Context) findTask(taskName string) error { // Exact ID match matched = true } else if frontMatter.ID == taskName { - // Plain name match (for custom IDs without task: prefix) + // Plain name match (for custom IDs without tasks/ prefix) matched = true - } else if strings.HasPrefix(frontMatter.ID, "task:") { + } else if strings.HasPrefix(frontMatter.ID, "tasks/") { // Extract basename from ID and compare - idBasename := strings.TrimPrefix(frontMatter.ID, "task:") + idBasename := strings.TrimPrefix(frontMatter.ID, "tasks/") if idBasename == taskName { matched = true } diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index 47da2e2..c6112d8 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -328,7 +328,7 @@ func TestContext_Run_Basic(t *testing.T) { { name: "task found by custom ID in frontmatter", setup: func(t *testing.T, dir string) { - createTask(t, dir, "actual-filename", "id: task:custom-task-id\nagent: cursor", "Task content with custom ID") + createTask(t, dir, "actual-filename", "id: tasks/custom-task-id\nagent: cursor", "Task content with custom ID") }, taskName: "custom-task-id", wantErr: false, @@ -336,13 +336,13 @@ func TestContext_Run_Basic(t *testing.T) { if !strings.Contains(result.Task.Content, "Task content with custom ID") { t.Errorf("expected task content, got %q", result.Task.Content) } - if result.Task.FrontMatter.ID != "task:custom-task-id" { - t.Errorf("expected task ID 'task:custom-task-id', got %q", result.Task.FrontMatter.ID) + if result.Task.FrontMatter.ID != "tasks/custom-task-id" { + t.Errorf("expected task ID 'tasks/custom-task-id', got %q", result.Task.FrontMatter.ID) } }, }, { - name: "task ID defaults to TYPE:basename when not specified", + name: "task ID defaults to TYPE/basename when not specified", setup: func(t *testing.T, dir string) { createTask(t, dir, "my-task-file", "agent: cursor", "Task content without ID field") }, @@ -352,8 +352,8 @@ func TestContext_Run_Basic(t *testing.T) { if !strings.Contains(result.Task.Content, "Task content without ID field") { t.Errorf("expected task content, got %q", result.Task.Content) } - if result.Task.FrontMatter.ID != "task:my-task-file" { - t.Errorf("expected task ID to default to 'task:my-task-file', got %q", result.Task.FrontMatter.ID) + if result.Task.FrontMatter.ID != "tasks/my-task-file" { + t.Errorf("expected task ID to default to 'tasks/my-task-file', got %q", result.Task.FrontMatter.ID) } }, }, diff --git a/pkg/codingcontext/markdown/frontmatter.go b/pkg/codingcontext/markdown/frontmatter.go index 44a18a5..9c4a935 100644 --- a/pkg/codingcontext/markdown/frontmatter.go +++ b/pkg/codingcontext/markdown/frontmatter.go @@ -10,8 +10,8 @@ import ( // BaseFrontMatter represents parsed YAML frontmatter from markdown files type BaseFrontMatter struct { // ID is a standard identifier field for all file types - // Format: TYPE:basename (e.g., task:fix-bug, rule:go-style) - // Defaults to TYPE:basename based on file type and filename without extension if not specified + // Format: TYPE/basename (e.g., tasks/fix-bug, rules/go-style) + // Defaults to TYPE/basename based on file type and filename without extension if not specified ID string `yaml:"id,omitempty" json:"id,omitempty"` Content map[string]any `json:"-" yaml:",inline"` diff --git a/pkg/codingcontext/markdown/markdown.go b/pkg/codingcontext/markdown/markdown.go index 7a9ba32..809b268 100644 --- a/pkg/codingcontext/markdown/markdown.go +++ b/pkg/codingcontext/markdown/markdown.go @@ -90,33 +90,33 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) }, nil } -// setDefaultID sets the ID field to TYPE:basename format if not already set -// Format: TYPE:basename where TYPE is task, rule, command, etc. +// setDefaultID sets the ID field to path-like format if not already set +// Format: TYPE/basename where TYPE is tasks, rules, commands, etc. (plural) func setDefaultID(frontMatter any, path string) { basename := getBasename(path) switch fm := frontMatter.(type) { case *TaskFrontMatter: if fm.ID == "" { - fm.ID = fmt.Sprintf("task:%s", basename) + fm.ID = fmt.Sprintf("tasks/%s", basename) } case *RuleFrontMatter: if fm.ID == "" { - fm.ID = fmt.Sprintf("rule:%s", basename) + fm.ID = fmt.Sprintf("rules/%s", basename) } case *CommandFrontMatter: if fm.ID == "" { - fm.ID = fmt.Sprintf("command:%s", basename) + fm.ID = fmt.Sprintf("commands/%s", basename) } case *SkillFrontMatter: // Skills have their own Name field, but we still set ID for consistency if fm.ID == "" { - fm.ID = fmt.Sprintf("skill:%s", basename) + fm.ID = fmt.Sprintf("skills/%s", basename) } case *BaseFrontMatter: if fm.ID == "" { // For generic BaseFrontMatter, use a generic type - fm.ID = fmt.Sprintf("file:%s", basename) + fm.ID = fmt.Sprintf("files/%s", basename) } } } diff --git a/pkg/codingcontext/markdown/markdown_test.go b/pkg/codingcontext/markdown/markdown_test.go index 63a2eec..4a91535 100644 --- a/pkg/codingcontext/markdown/markdown_test.go +++ b/pkg/codingcontext/markdown/markdown_test.go @@ -273,7 +273,7 @@ This task has no frontmatter. } } -// TestParseMarkdownFile_IDFieldDefaulting tests that the ID field is defaulted to TYPE:basename format +// TestParseMarkdownFile_IDFieldDefaulting tests that the ID field is defaulted to TYPE/basename format func TestParseMarkdownFile_IDFieldDefaulting(t *testing.T) { tests := []struct { name string @@ -286,50 +286,50 @@ func TestParseMarkdownFile_IDFieldDefaulting(t *testing.T) { name: "task with explicit ID field", filename: "my-task.md", content: `--- -id: task:custom-task-id +id: tasks/custom-task-id agent: cursor --- # My Task Content `, - wantID: "task:custom-task-id", + wantID: "tasks/custom-task-id", frontmatterType: "task", }, { - name: "task without ID field - defaults to TYPE:basename", + name: "task without ID field - defaults to TYPE/basename", filename: "fix-bug.md", content: `--- agent: cursor --- # Fix Bug Task `, - wantID: "task:fix-bug", + wantID: "tasks/fix-bug", frontmatterType: "task", }, { - name: "task without frontmatter - defaults to TYPE:basename", + name: "task without frontmatter - defaults to TYPE/basename", filename: "deploy-app.md", content: `# Deploy Application This task has no frontmatter. `, - wantID: "task:deploy-app", + wantID: "tasks/deploy-app", frontmatterType: "task", }, { name: "rule with explicit ID field", filename: "go-style.md", content: `--- -id: rule:go-coding-standards +id: rules/go-coding-standards languages: - go --- # Go Coding Standards `, - wantID: "rule:go-coding-standards", + wantID: "rules/go-coding-standards", frontmatterType: "rule", }, { - name: "rule without ID field - defaults to TYPE:basename", + name: "rule without ID field - defaults to TYPE/basename", filename: "testing-guidelines.md", content: `--- languages: @@ -337,29 +337,29 @@ languages: --- # Testing Guidelines `, - wantID: "rule:testing-guidelines", + wantID: "rules/testing-guidelines", frontmatterType: "rule", }, { name: "command with explicit ID field", filename: "setup-db.md", content: `--- -id: command:database-setup +id: commands/database-setup --- # Setup Database `, - wantID: "command:database-setup", + wantID: "commands/database-setup", frontmatterType: "command", }, { - name: "command without ID field - defaults to TYPE:basename", + name: "command without ID field - defaults to TYPE/basename", filename: "run-tests.md", content: `--- expand: true --- # Run Tests `, - wantID: "command:run-tests", + wantID: "commands/run-tests", frontmatterType: "command", }, { @@ -371,7 +371,7 @@ languages: --- # My Rule `, - wantID: "rule:my-rule", + wantID: "rules/my-rule", frontmatterType: "rule", }, { From 1f5023a2be61bd021f734a84feb1b3c819e4d7f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 23:02:48 +0000 Subject: [PATCH 7/7] Merge main: Add explanatory logging and resolve conflicts - Merged commit 3e1cbf1 from main - Updated MatchesIncludes to return (bool, string) with reason - Adapted ID-based task matching to work with new visitor signature - Added filename fallback for task matching to maintain compatibility - All tests passing --- pkg/codingcontext/context.go | 76 ++++++---- pkg/codingcontext/selectors/selectors.go | 47 +++++- pkg/codingcontext/selectors/selectors_test.go | 138 +++++++++++++++++- 3 files changed, 227 insertions(+), 34 deletions(-) diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 0f192cd..698d7cf 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -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. @@ -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) @@ -105,9 +110,6 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi } // findTask searches for a task markdown file and returns it with parameters substituted -// Tasks can be found by: -// 1. ID field matching "tasks/taskName" or just "taskName" (for backward compatibility) -// 2. Filename without extension matching taskName (fallback for files without ID) func (cc *Context) findTask(taskName string) error { // Add task name to includes so rules can be filtered cc.includes.SetValue("task_name", taskName) @@ -115,33 +117,38 @@ func (cc *Context) findTask(taskName string) error { taskFound := false expectedID := fmt.Sprintf("tasks/%s", taskName) - err := cc.visitMarkdownFiles(taskSearchPaths, func(path string) error { - // Parse the file to access frontmatter - var frontMatter markdown.TaskFrontMatter - md, err := markdown.ParseMarkdownFile(path, &frontMatter) - if err != nil { - return fmt.Errorf("failed to parse task file %s: %w", path, err) - } - - // Check if this task matches by ID field + err := cc.visitMarkdownFiles(taskSearchPaths, func(path string, fm *markdown.BaseFrontMatter) error { + // Check if this task matches by ID field or filename // ID can be: // - Full format: "tasks/fix-bug" // - Just the name: "fix-bug" (matches the basename part) - // After parsing, ID is guaranteed to be set (either from frontmatter or defaulted) + // - Or match by filename for backward compatibility matched := false - if frontMatter.ID == expectedID { + + // First, try ID-based matching + if fm.ID == expectedID { // Exact ID match matched = true - } else if frontMatter.ID == taskName { + } else if fm.ID == taskName { // Plain name match (for custom IDs without tasks/ prefix) matched = true - } else if strings.HasPrefix(frontMatter.ID, "tasks/") { + } else if strings.HasPrefix(fm.ID, "tasks/") { // Extract basename from ID and compare - idBasename := strings.TrimPrefix(frontMatter.ID, "tasks/") + idBasename := strings.TrimPrefix(fm.ID, "tasks/") if idBasename == taskName { matched = true } } + + // Fall back to filename matching if ID doesn't match + // This handles cases where files don't have explicit IDs + if !matched { + baseName := filepath.Base(path) + ext := filepath.Ext(baseName) + if strings.TrimSuffix(baseName, ext) == taskName { + matched = true + } + } if !matched { return nil @@ -149,6 +156,13 @@ func (cc *Context) findTask(taskName string) error { taskFound = true + // Parse the full task frontmatter and content + var frontMatter markdown.TaskFrontMatter + md, err := markdown.ParseMarkdownFile(path, &frontMatter) + if err != nil { + return fmt.Errorf("failed to parse task file %s: %w", path, err) + } + // Extract selector labels from task frontmatter and add them to cc.includes. // This combines CLI selectors (from -s flag) with task selectors using OR logic: // rules match if their frontmatter value matches ANY selector value for a given key. @@ -215,7 +229,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 }) @@ -236,7 +250,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 { @@ -266,6 +280,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 { @@ -527,7 +543,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 { @@ -554,7 +570,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) @@ -646,7 +664,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 } @@ -678,7 +701,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) } } diff --git a/pkg/codingcontext/selectors/selectors.go b/pkg/codingcontext/selectors/selectors.go index b4b91a8..a9c19c9 100644 --- a/pkg/codingcontext/selectors/selectors.go +++ b/pkg/codingcontext/selectors/selectors.go @@ -84,12 +84,24 @@ 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 { @@ -97,11 +109,34 @@ func (includes *Selectors) MatchesIncludes(frontmatter markdown.BaseFrontMatter) 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)" } diff --git a/pkg/codingcontext/selectors/selectors_test.go b/pkg/codingcontext/selectors/selectors_test.go index c99b6b9..74fccdc 100644 --- a/pkg/codingcontext/selectors/selectors_test.go +++ b/pkg/codingcontext/selectors/selectors_test.go @@ -1,6 +1,7 @@ package selectors import ( + "strings" "testing" "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" @@ -249,8 +250,9 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { tt.setupSelectors(s) } - if got := s.MatchesIncludes(tt.frontmatter); got != tt.wantMatch { - t.Errorf("MatchesIncludes() = %v, want %v", got, tt.wantMatch) + gotMatch, gotReason := s.MatchesIncludes(tt.frontmatter) + if gotMatch != tt.wantMatch { + t.Errorf("MatchesIncludes() = %v, want %v (reason: %s)", gotMatch, tt.wantMatch, gotReason) } }) } @@ -266,3 +268,135 @@ func TestSelectorMap_String(t *testing.T) { t.Error("String() returned empty string") } } + +func TestSelectorMap_MatchesIncludesReasons(t *testing.T) { + tests := []struct { + name string + selectors []string + setupSelectors func(s Selectors) + frontmatter markdown.BaseFrontMatter + wantMatch bool + wantReason string + checkReason func(t *testing.T, reason string) // For cases where reason order varies + }{ + { + name: "single selector - match", + selectors: []string{"env=production"}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, + wantMatch: true, + wantReason: "matched selectors: env=production", + }, + { + name: "single selector - no match", + selectors: []string{"env=production"}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "development"}}, + wantMatch: false, + wantReason: "selectors did not match: env=development (expected env=production)", + }, + { + name: "single selector - key missing (allowed)", + selectors: []string{"env=production"}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"language": "go"}}, + wantMatch: true, + wantReason: "no selectors specified (included by default)", + }, + { + name: "multiple selectors - all match", + selectors: []string{"env=production", "language=go"}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production", "language": "go"}}, + wantMatch: true, + checkReason: func(t *testing.T, reason string) { + if !strings.Contains(reason, "matched selectors:") { + t.Errorf("Expected reason to contain 'matched selectors:', got %q", reason) + } + if !strings.Contains(reason, "env=production") || !strings.Contains(reason, "language=go") { + t.Errorf("Expected reason to contain both selectors, got %q", reason) + } + }, + }, + { + name: "multiple selectors - one doesn't match", + selectors: []string{"env=production", "language=go"}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production", "language": "python"}}, + wantMatch: false, + wantReason: "selectors did not match: language=python (expected language=go)", + }, + { + name: "empty selectors", + selectors: []string{}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, + wantMatch: true, + wantReason: "", + }, + { + name: "array selector - match", + selectors: []string{}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule2"}}, + wantMatch: true, + wantReason: "matched selectors: rule_name=rule2", + setupSelectors: func(s Selectors) { + s.SetValue("rule_name", "rule1") + s.SetValue("rule_name", "rule2") + s.SetValue("rule_name", "rule3") + }, + }, + { + name: "array selector - no match", + selectors: []string{}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule4"}}, + wantMatch: false, + setupSelectors: func(s Selectors) { + s.SetValue("rule_name", "rule1") + s.SetValue("rule_name", "rule2") + s.SetValue("rule_name", "rule3") + }, + checkReason: func(t *testing.T, reason string) { + if !strings.Contains(reason, "selectors did not match:") { + t.Errorf("Expected reason to start with 'selectors did not match:', got %q", reason) + } + if !strings.Contains(reason, "rule_name=rule4") { + t.Errorf("Expected reason to contain 'rule_name=rule4', got %q", reason) + } + if !strings.Contains(reason, "rule1") || !strings.Contains(reason, "rule2") || !strings.Contains(reason, "rule3") { + t.Errorf("Expected reason to contain all expected values, got %q", reason) + } + }, + }, + { + name: "boolean value conversion", + selectors: []string{"is_active=true"}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"is_active": true}}, + wantMatch: true, + wantReason: "matched selectors: is_active=true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := make(Selectors) + for _, sel := range tt.selectors { + if err := s.Set(sel); err != nil { + t.Fatalf("Set() error = %v", err) + } + } + + // Set up array selectors if provided + if tt.setupSelectors != nil { + tt.setupSelectors(s) + } + + gotMatch, gotReason := s.MatchesIncludes(tt.frontmatter) + + if gotMatch != tt.wantMatch { + t.Errorf("MatchesIncludes() match = %v, want %v (reason: %s)", gotMatch, tt.wantMatch, gotReason) + } + + // Check reason + if tt.checkReason != nil { + tt.checkReason(t, gotReason) + } else if gotReason != tt.wantReason { + t.Errorf("MatchesIncludes() reason = %q, want %q", gotReason, tt.wantReason) + } + }) + } +}