diff --git a/README.md b/README.md index 3282b45..40dc096 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ When working with AI coding agents (like GitHub Copilot, ChatGPT, Claude, etc.), 2. **Creating task-specific prompts** - Define templated prompts for common tasks (e.g., "add feature", "fix bug", "refactor") 3. **Automating environment setup** - Package bootstrap scripts that prepare the environment before an agent starts work 4. **Filtering context dynamically** - Use selectors to include only relevant context (e.g., production vs. development, Python vs. Go) -5. **Composing everything together** - Generate a single `prompt.md` file combining all relevant context and the task prompt +5. **Composing everything together** - Generate three separate markdown files: `persona.md`, `memories.md`, and `task.md` ## When to Use @@ -36,10 +36,12 @@ The basic workflow is: 1. **Organize your context** - Create persona files (optional), memory files (shared context), and task files (task-specific instructions) 2. **Run the CLI** - Execute `coding-context [options] [persona-name]` 3. **Get assembled output** - The tool generates: - - `prompt.md` - Combined persona (if specified) + memories + task with template variables filled in + - `persona.md` - Persona content (always created, can be empty if no persona is specified) + - `memories.md` - All included memory files combined + - `task.md` - Task prompt with template variables filled in - `bootstrap` - Executable script to set up the environment - `bootstrap.d/` - Individual bootstrap scripts from your memory files -4. **Use with AI agents** - Share `prompt.md` with your AI coding agent, or run `./bootstrap` to prepare the environment first +4. **Use with AI agents** - Share the generated markdown files with your AI coding agent, or run `./bootstrap` to prepare the environment first **Visual flow:** ``` @@ -48,16 +50,11 @@ The basic workflow is: | (optional) | | | | (task-name.md) | +----------+-----------+ +----------+----------+ +------------+-------------+ | | | - | Apply template params | Filter by selectors | Apply template params + | No expansion | Filter by selectors | Apply template params v v v +----------------------+ +---------------------+ +--------------------------+ -| Rendered Persona +------>+ Filtered Memories +------>+ Rendered Task | -+----------------------+ +---------------------+ +------------+-------------+ - | - v - +----------------------------+ - | prompt.md (combined output)| - +----------------------------+ +| persona.md | | memories.md | | task.md | ++----------------------+ +---------------------+ +--------------------------+ ``` ## Installation @@ -177,22 +174,27 @@ coding-context -p taskName="Fix Bug" -p language=Go my-task coding-context -p taskName="Fix Bug" -p language=Go my-task expert ``` -**Result:** This generates `./prompt.md` combining your persona (if specified), memories, and the task prompt with template variables filled in. You can now share this complete context with your AI coding agent! +**Result:** This generates three files: `./persona.md` (if persona is specified), `./memories.md`, and `./task.md` with template variables filled in. You can now share these files with your AI coding agent! + +**What you'll see in the generated files (with persona):** -**What you'll see in `prompt.md` (with persona):** +`persona.md`: ```markdown # Expert Developer You are an expert developer with deep knowledge of best practices. +``` - +`memories.md`: +```markdown # Project Context - Framework: Go CLI - Purpose: Manage AI agent context +``` - - +`task.md`: +```markdown # Task: Fix Bug Please help me with this task. The project uses Go. @@ -222,7 +224,7 @@ Each directory should contain: ### Persona Files -Optional persona files define the role or character the AI agent should assume. Personas are output **first** in the generated `prompt.md`, before memories and tasks. +Optional persona files define the role or character the AI agent should assume. Personas are output to `persona.md` when specified. **Important:** Persona files do NOT support template variable expansion. They are included as-is in the output. @@ -239,7 +241,7 @@ Run with: coding-context add-feature expert ``` -This will look for `expert.md` in the persona directories. The persona is optional - if you don't specify a persona name as the second argument, the output will contain only memories and the task. +This will look for `expert.md` in the persona directories and output it to `persona.md`. The persona is optional – if you don't specify a persona name as the second argument, `persona.md` will still be generated but will be empty, alongside `memories.md` and `task.md`. ### Prompt Files @@ -393,7 +395,9 @@ When the tool processes these two files, it will include only one of them based ## Output Files -- **`prompt.md`** - Combined output with all memories and the task prompt +- **`persona.md`** - Persona content (always created, can be empty if no persona is specified) +- **`memories.md`** - Combined output with all filtered memory files +- **`task.md`** - Task prompt with template variables expanded - **`bootstrap`** - Executable script that runs all bootstrap scripts from memories - **`bootstrap.d/`** - Individual bootstrap scripts (SHA256 named) diff --git a/integration_test.go b/integration_test.go index 840ed36..5b2c164 100644 --- a/integration_test.go +++ b/integration_test.go @@ -99,10 +99,19 @@ Please help with this task. } } - // Check that the prompt.md file was created - promptOutput := filepath.Join(outputDir, "prompt.md") - if _, err := os.Stat(promptOutput); os.IsNotExist(err) { - t.Errorf("prompt.md file was not created") + // Check that the three output files were created + personaOutput := filepath.Join(outputDir, "persona.md") + memoriesOutput := filepath.Join(outputDir, "memories.md") + taskOutput := filepath.Join(outputDir, "task.md") + + if _, err := os.Stat(personaOutput); os.IsNotExist(err) { + t.Errorf("persona.md file was not created") + } + if _, err := os.Stat(memoriesOutput); os.IsNotExist(err) { + t.Errorf("memories.md file was not created") + } + if _, err := os.Stat(taskOutput); os.IsNotExist(err) { + t.Errorf("task.md file was not created") } } @@ -259,10 +268,15 @@ Please help with this task. t.Errorf("expected 0 bootstrap files, got %d", len(files)) } - // Check that the prompt.md file was still created - promptOutput := filepath.Join(outputDir, "prompt.md") - if _, err := os.Stat(promptOutput); os.IsNotExist(err) { - t.Errorf("prompt.md file was not created") + // Check that the three output files were still created + memoriesOutput := filepath.Join(outputDir, "memories.md") + taskOutput := filepath.Join(outputDir, "task.md") + + if _, err := os.Stat(memoriesOutput); os.IsNotExist(err) { + t.Errorf("memories.md file was not created") + } + if _, err := os.Stat(taskOutput); os.IsNotExist(err) { + t.Errorf("task.md file was not created") } } @@ -376,10 +390,10 @@ func TestSelectorFiltering(t *testing.T) { t.Fatalf("failed to run binary: %v\n%s", err, output) } - promptOutput := filepath.Join(outputDir, "prompt.md") - content, err := os.ReadFile(promptOutput) + memoriesOutput := filepath.Join(outputDir, "memories.md") + content, err := os.ReadFile(memoriesOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read memories output: %v", err) } contentStr := string(content) if !strings.Contains(contentStr, "Prod content") { @@ -406,9 +420,9 @@ func TestSelectorFiltering(t *testing.T) { t.Fatalf("failed to run binary: %v\n%s", err, output) } - content, err = os.ReadFile(promptOutput) + content, err = os.ReadFile(memoriesOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read memories output: %v", err) } contentStr = string(content) if !strings.Contains(contentStr, "Prod content") { @@ -434,9 +448,9 @@ func TestSelectorFiltering(t *testing.T) { t.Fatalf("failed to run binary: %v\n%s", err, output) } - content, err = os.ReadFile(promptOutput) + content, err = os.ReadFile(memoriesOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read memories output: %v", err) } contentStr = string(content) if strings.Contains(contentStr, "Prod content") { @@ -462,9 +476,9 @@ func TestSelectorFiltering(t *testing.T) { t.Fatalf("failed to run binary: %v\n%s", err, output) } - content, err = os.ReadFile(promptOutput) + content, err = os.ReadFile(memoriesOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read memories output: %v", err) } contentStr = string(content) if !strings.Contains(contentStr, "Prod content") { @@ -490,9 +504,9 @@ func TestSelectorFiltering(t *testing.T) { t.Fatalf("failed to run binary: %v\n%s", err, output) } - content, err = os.ReadFile(promptOutput) + content, err = os.ReadFile(memoriesOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read memories output: %v", err) } contentStr = string(content) if !strings.Contains(contentStr, "Prod content") { @@ -555,11 +569,11 @@ The project is for $company. t.Fatalf("failed to run binary: %v\n%s", err, output) } - // Read the output - promptOutput := filepath.Join(outputDir, "prompt.md") - content, err := os.ReadFile(promptOutput) + // Read the task output (template expansion happens in task.md) + taskOutput := filepath.Join(outputDir, "task.md") + content, err := os.ReadFile(taskOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read task output: %v", err) } contentStr := string(content) @@ -617,11 +631,11 @@ Missing var: ${missingVar} t.Fatalf("failed to run binary: %v\n%s", err, output) } - // Read the output - promptOutput := filepath.Join(outputDir, "prompt.md") - content, err := os.ReadFile(promptOutput) + // Read the task output (template expansion happens in task.md) + taskOutput := filepath.Join(outputDir, "task.md") + content, err := os.ReadFile(taskOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read task output: %v", err) } contentStr := string(content) @@ -919,10 +933,10 @@ func TestTaskNameBuiltinFilter(t *testing.T) { t.Fatalf("failed to run binary: %v\n%s", err, output) } - promptOutput := filepath.Join(outputDir, "prompt.md") - content, err := os.ReadFile(promptOutput) + memoriesOutput := filepath.Join(outputDir, "memories.md") + content, err := os.ReadFile(memoriesOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read memories output: %v", err) } contentStr := string(content) if !strings.Contains(contentStr, "Deploy-specific content") { @@ -945,9 +959,9 @@ func TestTaskNameBuiltinFilter(t *testing.T) { t.Fatalf("failed to run binary: %v\n%s", err, output) } - content, err = os.ReadFile(promptOutput) + content, err = os.ReadFile(memoriesOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read memories output: %v", err) } contentStr = string(content) if strings.Contains(contentStr, "Deploy-specific content") { @@ -1030,44 +1044,46 @@ Please help with ${feature}. t.Fatalf("failed to run binary: %v\n%s", err, output) } - // Check the output - promptOutput := filepath.Join(outputDir, "prompt.md") - content, err := os.ReadFile(promptOutput) + // Check the output - now we have three separate files + personaOutput := filepath.Join(outputDir, "persona.md") + personaBytes, err := os.ReadFile(personaOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read persona output: %v", err) } - - contentStr := string(content) - - // Verify persona appears first - expertIdx := strings.Index(contentStr, "Expert Persona") - contextIdx := strings.Index(contentStr, "# Context") - taskIdx := strings.Index(contentStr, "# Task") - - if expertIdx == -1 { - t.Errorf("Expected to find 'Expert Persona' in output") - } - if contextIdx == -1 { - t.Errorf("Expected to find '# Context' in output") + + memoriesOutput := filepath.Join(outputDir, "memories.md") + memoriesBytes, err2 := os.ReadFile(memoriesOutput) + if err2 != nil { + t.Fatalf("failed to read memories output: %v", err2) } - if taskIdx == -1 { - t.Errorf("Expected to find '# Task' in output") + + taskOutput := filepath.Join(outputDir, "task.md") + taskBytes, err3 := os.ReadFile(taskOutput) + if err3 != nil { + t.Fatalf("failed to read task output: %v", err3) } - // Verify order: persona -> context -> task - if expertIdx > contextIdx { - t.Errorf("Persona should appear before context. Persona at %d, Context at %d", expertIdx, contextIdx) + // Verify persona content + personaStr := string(personaBytes) + if !strings.Contains(personaStr, "Expert Persona") { + t.Errorf("Expected to find 'Expert Persona' in persona.md") } - if contextIdx > taskIdx { - t.Errorf("Context should appear before task. Context at %d, Task at %d", contextIdx, taskIdx) + if !strings.Contains(personaStr, "You are an expert in Go") { + t.Errorf("Expected persona content to remain as-is without template expansion") } - // Verify persona content is not expanded (no template substitution) - if !strings.Contains(contentStr, "You are an expert in Go") { - t.Errorf("Expected persona content to remain as-is without template expansion") + // Verify memories content + memoriesStr := string(memoriesBytes) + if !strings.Contains(memoriesStr, "# Context") { + t.Errorf("Expected to find '# Context' in memories.md") + } + + // Verify task content + taskStr := string(taskBytes) + if !strings.Contains(taskStr, "# Task") { + t.Errorf("Expected to find '# Task' in task.md") } - // Verify task template substitution still works - if !strings.Contains(contentStr, "Please help with auth") { + if !strings.Contains(taskStr, "Please help with auth") { t.Errorf("Expected task template to be expanded with feature=auth") } } @@ -1125,21 +1141,25 @@ Please help. t.Fatalf("failed to run binary without persona: %v\n%s", err, output) } - // Check the output - promptOutput := filepath.Join(outputDir, "prompt.md") - content, err := os.ReadFile(promptOutput) + // Check the memories and task outputs + memoriesOutput := filepath.Join(outputDir, "memories.md") + memoriesBytes, err := os.ReadFile(memoriesOutput) if err != nil { - t.Fatalf("failed to read prompt output: %v", err) + t.Fatalf("failed to read memories output: %v", err) } - contentStr := string(content) + taskOutput := filepath.Join(outputDir, "task.md") + taskBytes, err2 := os.ReadFile(taskOutput) + if err2 != nil { + t.Fatalf("failed to read task output: %v", err2) + } // Verify context and task are present - if !strings.Contains(contentStr, "# Context") { - t.Errorf("Expected to find '# Context' in output") + if !strings.Contains(string(memoriesBytes), "# Context") { + t.Errorf("Expected to find '# Context' in memories.md") } - if !strings.Contains(contentStr, "# Task") { - t.Errorf("Expected to find '# Task' in output") + if !strings.Contains(string(taskBytes), "# Task") { + t.Errorf("Expected to find '# Task' in task.md") } } @@ -1241,19 +1261,29 @@ func TestWorkDirOption(t *testing.T) { t.Fatalf("failed to run binary with -C option: %v\n%s", err, output) } - // Verify that prompt.md was created in the output directory - promptFile := filepath.Join(outputDir, "prompt.md") - if _, err := os.Stat(promptFile); os.IsNotExist(err) { - t.Errorf("prompt.md was not created in output directory") + // Verify that the three output files were created in the output directory + memoriesOutFile := filepath.Join(outputDir, "memories.md") + taskOutFile := filepath.Join(outputDir, "task.md") + personaOutFile := filepath.Join(outputDir, "persona.md") + + var statErr error + if _, statErr = os.Stat(memoriesOutFile); os.IsNotExist(statErr) { + t.Errorf("memories.md was not created in output directory") + } + if _, statErr = os.Stat(taskOutFile); os.IsNotExist(statErr) { + t.Errorf("task.md was not created in output directory") + } + if _, statErr = os.Stat(personaOutFile); os.IsNotExist(statErr) { + t.Errorf("persona.md was not created in output directory") } // Verify the content includes the memory - content, err := os.ReadFile(promptFile) + content, err := os.ReadFile(memoriesOutFile) if err != nil { - t.Fatalf("failed to read prompt.md: %v", err) + t.Fatalf("failed to read memories.md: %v", err) } if !strings.Contains(string(content), "Test Memory") { - t.Errorf("prompt.md does not contain expected memory content") + t.Errorf("memories.md does not contain expected memory content") } } diff --git a/main.go b/main.go index 08d0810..293f444 100644 --- a/main.go +++ b/main.go @@ -120,16 +120,17 @@ func run(ctx context.Context, args []string) error { return fmt.Errorf("failed to create bootstrap dir: %w", err) } - output, err := os.Create(filepath.Join(outputDir, "prompt.md")) - if err != nil { - return fmt.Errorf("failed to create prompt file: %w", err) - } - defer output.Close() - // Track total tokens var totalTokens int - // Process persona first if provided (should be first in output) + // Create persona.md file + personaOutput, err := os.Create(filepath.Join(outputDir, "persona.md")) + if err != nil { + return fmt.Errorf("failed to create persona file: %w", err) + } + defer personaOutput.Close() + + // Process persona first if provided if personaName != "" { personaFound := false for _, path := range personas { @@ -159,7 +160,7 @@ func run(ctx context.Context, args []string) error { fmt.Fprintf(os.Stdout, "Using persona file: %s (~%d tokens)\n", path, tokens) // Personas don't need variable expansion or filters - if _, err := output.WriteString(content + "\n\n"); err != nil { + if _, err := personaOutput.WriteString(content); err != nil { return fmt.Errorf("failed to write persona: %w", err) } @@ -172,6 +173,13 @@ func run(ctx context.Context, args []string) error { } } + // Create memories.md file + memoriesOutput, err := os.Create(filepath.Join(outputDir, "memories.md")) + if err != nil { + return fmt.Errorf("failed to create memories file: %w", err) + } + defer memoriesOutput.Close() + memoryBasenames := make(map[string]bool) for _, memory := range memories { @@ -241,8 +249,8 @@ func run(ctx context.Context, args []string) error { } } - if _, err := output.WriteString(content + "\n\n"); err != nil { - return fmt.Errorf("failed to write to output file: %w", err) + if _, err := memoriesOutput.WriteString(content + "\n\n"); err != nil { + return fmt.Errorf("failed to write to memories file: %w", err) } return nil @@ -257,6 +265,13 @@ func run(ctx context.Context, args []string) error { return fmt.Errorf("failed to write bootstrap file: %w", err) } + // Create task.md file + taskOutput, err := os.Create(filepath.Join(outputDir, "task.md")) + if err != nil { + return fmt.Errorf("failed to create task file: %w", err) + } + defer taskOutput.Close() + for _, path := range tasks { stat, err := os.Stat(path) if os.IsNotExist(err) { @@ -291,8 +306,8 @@ func run(ctx context.Context, args []string) error { totalTokens += tokens fmt.Fprintf(os.Stdout, "Using task file: %s (~%d tokens)\n", path, tokens) - if _, err := output.WriteString(expanded); err != nil { - return fmt.Errorf("failed to write expanded prompt: %w", err) + if _, err := taskOutput.WriteString(expanded); err != nil { + return fmt.Errorf("failed to write expanded task: %w", err) } // Print total token count diff --git a/memory_name_test.go b/memory_name_test.go index 30a9b95..a86ce6b 100644 --- a/memory_name_test.go +++ b/memory_name_test.go @@ -66,16 +66,16 @@ This is the second memory.` } // Check the output - promptBytes, err := os.ReadFile(filepath.Join(tmpDir, "prompt.md")) + memoriesBytes, err := os.ReadFile(filepath.Join(tmpDir, "memories.md")) if err != nil { - t.Fatalf("Failed to read prompt.md: %v", err) + t.Fatalf("Failed to read memories.md: %v", err) } - prompt := string(promptBytes) + memoriesContent := string(memoriesBytes) // We expect only one of the memories to be included - hasFirst := strings.Contains(prompt, "This is the first memory.") - hasSecond := strings.Contains(prompt, "This is the second memory.") + hasFirst := strings.Contains(memoriesContent, "This is the first memory.") + hasSecond := strings.Contains(memoriesContent, "This is the second memory.") if hasFirst == hasSecond { - t.Errorf("Expected only one memory to be included, but got: %s", prompt) + t.Errorf("Expected only one memory to be included, but got: %s", memoriesContent) } }