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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ”’ Security & Privacy | 🟠 Major | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== go.mod ==\n'
cat -n go.mod | sed -n '1,80p'

printf '\n== x/sys references ==\n'
rg -n --hidden --glob '!**/vendor/**' 'golang\.org/x/sys|x/sys' .

Repository: Gentleman-Programming/gentle-ai

Length of output: 1985


🌐 Web query:

GO-2026-5024 golang.org/x/sys windows vulnerable version advisory

πŸ’‘ Result:

GO-2026-5024 (also tracked as CVE-2026-39824) is a security vulnerability in the golang.org/x/sys/windows package [1][2]. The issue involves an integer overflow in the NewNTUnicodeString function [3][4]. Specifically, the function failed to properly check for string length overflows; when provided with a string exceeding the maximum size of a NTUnicodeString (a 16-bit number of bytes), it would return a truncated string rather than an error [1][2]. This vulnerability affects versions of golang.org/x/sys prior to v0.44.0 [1][2]. Users are advised to update to a patched version to resolve this issue [3][5]. The Go Security Team officially announced the fix in version v0.45.0, though the underlying patch was included in v0.44.0 [3][5].

Citations:


Bump golang.org/x/sys off v0.38.0. v0.38.0 is in the affected range for GO-2026-5024 / CVE-2026-39824, so this keeps Windows builds on a vulnerable release. Upgrade to a patched version (v0.44.0+, or v0.45.0 if you want the Go team’s fix).

🧰 Tools
πŸͺ› OSV Scanner (2.4.0)

[LOW] 11-11: golang.org/x/sys 0.38.0: Invoking integer overflow in NewNTUnicodeString in golang.org/x/sys/windows

(GO-2026-5024)

πŸ€– 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 `@go.mod` at line 11, Update the golang.org/x/sys dependency in go.mod to move
off v0.38.0 and onto a fixed release such as v0.44.0 or later, preferably
v0.45.0 if you want the Go team’s patched version. Make sure any corresponding
module metadata or lockfile state is refreshed so the dependency graph
consistently uses the upgraded version.

Source: Linters/SAST tools

)

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")
}
}
28 changes: 27 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,27 @@ func persistAssignments(homeDir string, selection model.Selection) {
current.ModelAssignments = nil
}
}
if selection.VSCodeModelAssignments != nil {
if len(selection.VSCodeModelAssignments) > 0 {
current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
} else {
current.VSCodeModelAssignments = nil
}
}
_ = 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