Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@ After creating a profile, open OpenCode and press **Tab** to switch between `gen

**Full guide**: [OpenCode SDD Profiles](docs/opencode-profiles.md)

### VS Code Copilot SDD Agents

Gentle-AI installs three VS Code layers: global instructions/rules in `Code/User/prompts/gentle-ai.instructions.md`, native custom agents in `~/.copilot/agents`, and SDD skills/shared conventions in `~/.copilot/skills`.

When a VS Code model assignment is missing, invalid, or unvalidated, generated agents omit `model` and inherit the parent chat model instead of failing sync.

If VS Code also discovers Gentle-AI's Claude-format internal agents, sync marks those managed `sdd-*` and `jd-*` files `user-invocable: false` so the visible picker stays focused on `sdd-orchestrator`.

### Engram (Persistent Memory)

Your AI agent automatically remembers decisions, bugs, and context across sessions. You don't need to do anything -- but when you do:
Expand Down
9 changes: 6 additions & 3 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,13 @@ Kiro uses native custom agents in `~/.kiro/agents/`. `gentle-ai` writes phase ag

### VS Code Copilot

- Uses the `runSubagent` tool with support for parallel execution
- Skills at `~/.copilot/skills/`
- System prompt at `Code/User/prompts/gentle-ai.instructions.md`
- Native custom agents at `~/.copilot/agents/*.agent.md`: one user-invocable `sdd-orchestrator` coordinator plus hidden SDD phase agents
- Delegates through VS Code Copilot's native sub-agent / `runSubagent` flow
- Skills and shared SDD conventions at `~/.copilot/skills/`
- Global instructions/rules at `Code/User/prompts/gentle-ai.instructions.md`
- MCP config at `Code/User/mcp.json`
- Missing, invalid, or unvalidated model assignments omit `model` and inherit the parent chat model
- VS Code may also discover Claude-format agents in `~/.claude/agents`; Gentle-AI marks its managed internal `sdd-*` and `jd-*` Claude agents `user-invocable: false` so the picker stays focused on `sdd-orchestrator`

### Codex

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/mattn/go-isatty v0.0.20
github.com/rivo/uniseg v0.4.7
golang.org/x/sys v0.38.0
)

require (
Expand All @@ -28,6 +29,5 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
9 changes: 5 additions & 4 deletions internal/agents/vscode/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func (a *Adapter) InstallCommand(_ system.PlatformProfile) ([][]string, error) {
// VS Code Copilot reads .instructions.md files from the VS Code User prompts folder.
// Skills are loaded from ~/.copilot/skills/ (global), .github/skills/ (workspace),
// ~/.claude/skills/, and .claude/skills/. We target ~/.copilot/skills/ for global reach.
// Native agent files are installed globally under ~/.copilot/agents/.

func (a *Adapter) GlobalConfigDir(homeDir string) string {
return filepath.Join(homeDir, ".copilot")
Expand Down Expand Up @@ -133,15 +134,15 @@ func (a *Adapter) CommandsDir(_ string) string {
}

func (a *Adapter) SupportsSubAgents() bool {
return false
return true
}

func (a *Adapter) SubAgentsDir(_ string) string {
return ""
func (a *Adapter) SubAgentsDir(homeDir string) string {
return filepath.Join(homeDir, ".copilot", "agents")
}

func (a *Adapter) EmbeddedSubAgentsDir() string {
return ""
return "vscode/agents"
}

func (a *Adapter) SupportsSkills() bool {
Expand Down
23 changes: 23 additions & 0 deletions internal/agents/vscode/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package vscode
import (
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/gentleman-programming/gentle-ai/internal/model"
Expand Down Expand Up @@ -91,3 +92,25 @@ func TestMCPConfigPathUsesVSCodeUserProfile(t *testing.T) {
}
}
}

func TestSubAgentSupportUsesCopilotUserAgentsDir(t *testing.T) {
a := NewAdapter()
home := "/tmp/home"

if !a.SupportsSubAgents() {
t.Fatal("VS Code adapter must advertise native sub-agent support")
}

got := a.SubAgentsDir(home)
want := filepath.Join(home, ".copilot", "agents")
if got != want {
t.Fatalf("SubAgentsDir() = %q, want %q", got, want)
}
if got == filepath.Join(home, ".github", "agents") || strings.Contains(got, string(filepath.Separator)+".github"+string(filepath.Separator)) {
t.Fatalf("SubAgentsDir() must not target workspace .github/agents, got %q", got)
}

if got := a.EmbeddedSubAgentsDir(); got != "vscode/agents" {
t.Fatalf("EmbeddedSubAgentsDir() = %q, want %q", got, "vscode/agents")
}
}
24 changes: 23 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ func tuiExecute(
CodexCarrilModelAssignments: selection.CodexCarrilModelAssignments,
CodexPhaseModelAssignments: selection.CodexPhaseModelAssignments,
ModelAssignments: modelAssignmentsToState(selection.ModelAssignments),
VSCodeModelAssignments: modelAssignmentsToState(selection.VSCodeModelAssignments),
Persona: string(selection.Persona),
})
}
Expand Down Expand Up @@ -549,6 +550,9 @@ func applyOverrides(selection *model.Selection, overrides *model.SyncOverrides)
if overrides.ModelAssignments != nil {
selection.ModelAssignments = overrides.ModelAssignments
}
if overrides.VSCodeModelAssignments != nil {
selection.VSCodeModelAssignments = overrides.VSCodeModelAssignments
}
if overrides.ClaudeModelAssignments != nil {
selection.ClaudeModelAssignments = overrides.ClaudeModelAssignments
}
Expand Down Expand Up @@ -657,6 +661,9 @@ func loadPersistedAssignments(homeDir string, selection *model.Selection) {
}
selection.ModelAssignments = m
}
if len(selection.VSCodeModelAssignments) == 0 && len(s.VSCodeModelAssignments) > 0 {
selection.VSCodeModelAssignments = stateModelAssignmentsToModel(s.VSCodeModelAssignments)
}
}

// persistAssignments writes the model assignments from selection back to
Expand All @@ -672,10 +679,11 @@ func persistAssignments(homeDir string, selection model.Selection) {
selection.ClaudePhaseAssignments != nil ||
selection.KiroModelAssignments != nil ||
selection.ModelAssignments != nil ||
selection.VSCodeModelAssignments != nil ||
selection.CodexModelAssignments != nil ||
selection.CodexCarrilModelAssignments != nil ||
selection.CodexPhaseModelAssignments != nil
if len(selection.ClaudeModelAssignments) == 0 && len(selection.ClaudePhaseAssignments) == 0 && len(selection.KiroModelAssignments) == 0 && len(selection.ModelAssignments) == 0 && len(selection.CodexModelAssignments) == 0 && len(selection.CodexCarrilModelAssignments) == 0 && len(selection.CodexPhaseModelAssignments) == 0 && !hasAssignmentSignal {
if len(selection.ClaudeModelAssignments) == 0 && len(selection.ClaudePhaseAssignments) == 0 && len(selection.KiroModelAssignments) == 0 && len(selection.ModelAssignments) == 0 && len(selection.VSCodeModelAssignments) == 0 && len(selection.CodexModelAssignments) == 0 && len(selection.CodexCarrilModelAssignments) == 0 && len(selection.CodexPhaseModelAssignments) == 0 && !hasAssignmentSignal {
return
}
current, err := state.Read(homeDir)
Expand Down Expand Up @@ -738,9 +746,23 @@ func persistAssignments(homeDir string, selection model.Selection) {
current.ModelAssignments = nil
}
}
if len(selection.VSCodeModelAssignments) > 0 {
current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
}
Comment on lines +749 to +751

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Missing else branch to handle explicit clear signal for VSCodeModelAssignments.

When selection.VSCodeModelAssignments is a non-nil empty map (the explicit "reset to defaults" signal per the SyncOverrides contract in context snippet 3), the function should clear current.VSCodeModelAssignments by setting it to nil. Currently, only the non-empty write path is implemented. This breaks the nil vs empty-map override semantics that all other assignment maps follow (e.g. ClaudeModelAssignments at lines 698-703).

πŸ›‘οΈ Proposed fix to add the clear branch
-	if len(selection.VSCodeModelAssignments) > 0 {
-		current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
-	}
+	if selection.VSCodeModelAssignments != nil {
+		if len(selection.VSCodeModelAssignments) > 0 {
+			current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
+		} else {
+			current.VSCodeModelAssignments = nil
+		}
+	}
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if len(selection.VSCodeModelAssignments) > 0 {
current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
}
if selection.VSCodeModelAssignments != nil {
if len(selection.VSCodeModelAssignments) > 0 {
current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
} else {
current.VSCodeModelAssignments = nil
}
}
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/app/app.go` around lines 749 - 751, The current code for
VSCodeModelAssignments only handles the case when the map is non-empty, but it
lacks handling for the explicit "reset to defaults" signal when the map is
non-nil but empty. Add an else branch to the existing if condition that checks
len(selection.VSCodeModelAssignments) > 0. In this else branch, set
current.VSCodeModelAssignments to nil to clear it when an explicit empty map is
provided. This else branch should mirror the pattern already implemented for
other assignment maps like ClaudeModelAssignments to maintain consistent nil vs
empty-map override semantics throughout the function.

_ = state.Write(homeDir, current)
}

func stateModelAssignmentsToModel(m map[string]state.ModelAssignmentState) map[string]model.ModelAssignment {
if len(m) == 0 {
return nil
}
out := make(map[string]model.ModelAssignment, len(m))
for k, v := range m {
out[k] = model.ModelAssignment{ProviderID: v.ProviderID, ModelID: v.ModelID, Effort: v.Effort}
}
return out
}

// claudeAliasesToStrings converts a typed ClaudeModelAlias map to plain strings
// for JSON serialisation in state.json.
func claudeAliasesToStrings(m map[string]model.ClaudeModelAlias) map[string]string {
Expand Down
66 changes: 66 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,34 @@ func TestTuiSyncSDDProfileStrategyEmptyOverrideNoChange(t *testing.T) {
}
}

func TestApplyOverridesVSCodeModelAssignments(t *testing.T) {
selection := model.Selection{
VSCodeModelAssignments: map[string]model.ModelAssignment{
"sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"},
},
ModelAssignments: map[string]model.ModelAssignment{
"sdd-apply": {ProviderID: "anthropic", ModelID: "claude-opus-4"},
},
}
overrides := &model.SyncOverrides{
VSCodeModelAssignments: map[string]model.ModelAssignment{
"sdd-design": {ProviderID: "github-copilot", ModelID: "claude-sonnet-4"},
},
}

applyOverrides(&selection, overrides)

if _, exists := selection.VSCodeModelAssignments["sdd-apply"]; exists {
t.Fatal("VSCodeModelAssignments should be replaced as a whole map, not merged key-by-key")
}
if got := selection.VSCodeModelAssignments["sdd-design"].ProviderID; got != "github-copilot" {
t.Fatalf("VSCodeModelAssignments[sdd-design].ProviderID = %q, want github-copilot", got)
}
if got := selection.ModelAssignments["sdd-apply"].ProviderID; got != "anthropic" {
t.Fatalf("OpenCode ModelAssignments changed after VS Code override: provider = %q", got)
}
}

func boolPtr(b bool) *bool { return &b }

func TestTuiSyncTargetAgentsOverridePersistedInstallState(t *testing.T) {
Expand Down Expand Up @@ -666,6 +694,9 @@ func TestLoadPersistedAssignmentsPopulatesEmptySelection(t *testing.T) {
ModelAssignments: map[string]state.ModelAssignmentState{
"sdd-init": {ProviderID: "anthropic", ModelID: "claude-sonnet-4"},
},
VSCodeModelAssignments: map[string]state.ModelAssignmentState{
"sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"},
},
})
if err != nil {
t.Fatalf("state.Write: %v", err)
Expand All @@ -690,6 +721,10 @@ func TestLoadPersistedAssignmentsPopulatesEmptySelection(t *testing.T) {
if ma.ProviderID != "anthropic" || ma.ModelID != "claude-sonnet-4" {
t.Errorf("ModelAssignments[sdd-init] = %+v, want anthropic/claude-sonnet-4", ma)
}
vs := selection.VSCodeModelAssignments["sdd-apply"]
if vs.ProviderID != "github-copilot" || vs.ModelID != "gpt-4.1" {
t.Errorf("VSCodeModelAssignments[sdd-apply] = %+v, want github-copilot/gpt-4.1", vs)
}
}

// TestLoadPersistedAssignmentsDoesNotOverrideExisting verifies that when the
Expand All @@ -704,6 +739,9 @@ func TestLoadPersistedAssignmentsDoesNotOverrideExisting(t *testing.T) {
ModelAssignments: map[string]state.ModelAssignmentState{
"sdd-init": {ProviderID: "google", ModelID: "gemini-pro"},
},
VSCodeModelAssignments: map[string]state.ModelAssignmentState{
"sdd-apply": {ProviderID: "github-copilot", ModelID: "old-model"},
},
})
if err != nil {
t.Fatalf("state.Write: %v", err)
Expand All @@ -717,6 +755,9 @@ func TestLoadPersistedAssignmentsDoesNotOverrideExisting(t *testing.T) {
ModelAssignments: map[string]model.ModelAssignment{
"sdd-init": {ProviderID: "anthropic", ModelID: "claude-sonnet-4"},
},
VSCodeModelAssignments: map[string]model.ModelAssignment{
"sdd-apply": {ProviderID: "github-copilot", ModelID: "new-model"},
},
}
loadPersistedAssignments(home, &selection)

Expand All @@ -728,6 +769,9 @@ func TestLoadPersistedAssignmentsDoesNotOverrideExisting(t *testing.T) {
if ma.ProviderID != "anthropic" {
t.Errorf("ModelAssignments[sdd-init].ProviderID = %q, want %q (should not be overwritten)", ma.ProviderID, "anthropic")
}
if got := selection.VSCodeModelAssignments["sdd-apply"].ModelID; got != "new-model" {
t.Errorf("VSCodeModelAssignments[sdd-apply].ModelID = %q, want new-model (should not be overwritten)", got)
}
}

// TestPersistAssignmentsPreservesInstalledAgents verifies the read-merge-write
Expand Down Expand Up @@ -927,6 +971,28 @@ func TestPersistAndLoadKiroModelAssignments(t *testing.T) {
}
}

func TestPersistAndLoadVSCodeModelAssignments(t *testing.T) {
home := t.TempDir()

selection := model.Selection{
VSCodeModelAssignments: map[string]model.ModelAssignment{
"sdd-orchestrator": {ProviderID: "github-copilot", ModelID: "gpt-4.1"},
"sdd-apply": {ProviderID: "github-copilot", ModelID: "claude-sonnet-4", Effort: "low"},
},
}
persistAssignments(home, selection)

loaded := model.Selection{}
loadPersistedAssignments(home, &loaded)

if got := loaded.VSCodeModelAssignments["sdd-orchestrator"].ModelID; got != "gpt-4.1" {
t.Fatalf("VSCodeModelAssignments[sdd-orchestrator].ModelID = %q, want gpt-4.1", got)
}
if got := loaded.VSCodeModelAssignments["sdd-apply"].Effort; got != "low" {
t.Fatalf("VSCodeModelAssignments[sdd-apply].Effort = %q, want low", got)
}
}

// TestPersistAssignmentsNoOpWhenEmpty verifies that persistAssignments does
// not write to state.json when the selection has no assignments.
func TestPersistAssignmentsNoOpWhenEmpty(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion internal/assets/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package assets

import "embed"

//go:embed all:claude all:opencode all:generic all:skills all:gga all:gemini all:codex all:antigravity all:windsurf all:cursor all:kimi all:qwen all:kiro all:hermes
//go:embed all:claude all:opencode all:generic all:skills all:gga all:gemini all:codex all:antigravity all:windsurf all:cursor all:vscode all:kimi all:qwen all:kiro all:hermes
var FS embed.FS

// MustRead returns the content of an embedded file or panics.
Expand Down
Loading