diff --git a/README.md b/README.md index e8e4c735c..baf5d2859 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/agents.md b/docs/agents.md index 60d5f16aa..d2e532029 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 diff --git a/go.mod b/go.mod index b9d0c09d2..190ea0299 100644 --- a/go.mod +++ b/go.mod @@ -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 ( @@ -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 ) diff --git a/internal/agents/vscode/adapter.go b/internal/agents/vscode/adapter.go index abf0f4fdd..b54612982 100644 --- a/internal/agents/vscode/adapter.go +++ b/internal/agents/vscode/adapter.go @@ -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") @@ -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 { diff --git a/internal/agents/vscode/adapter_test.go b/internal/agents/vscode/adapter_test.go index e05091ab3..1e063662a 100644 --- a/internal/agents/vscode/adapter_test.go +++ b/internal/agents/vscode/adapter_test.go @@ -3,6 +3,7 @@ package vscode import ( "path/filepath" "runtime" + "strings" "testing" "github.com/gentleman-programming/gentle-ai/internal/model" @@ -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") + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 9e0d5fb9b..c763b7aa9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -448,6 +448,7 @@ func tuiExecute( CodexCarrilModelAssignments: selection.CodexCarrilModelAssignments, CodexPhaseModelAssignments: selection.CodexPhaseModelAssignments, ModelAssignments: modelAssignmentsToState(selection.ModelAssignments), + VSCodeModelAssignments: modelAssignmentsToState(selection.VSCodeModelAssignments), Persona: string(selection.Persona), }) } @@ -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 } @@ -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 @@ -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) @@ -738,9 +746,23 @@ func persistAssignments(homeDir string, selection model.Selection) { current.ModelAssignments = nil } } + if len(selection.VSCodeModelAssignments) > 0 { + current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments) + } _ = 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 { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 9d0ed7aad..3ab6e49fd 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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) { @@ -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) @@ -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 @@ -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) @@ -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) @@ -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 @@ -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) { diff --git a/internal/assets/assets.go b/internal/assets/assets.go index 0873ef665..457fd168e 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -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. diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go index 43b817fe7..ba7bb8802 100644 --- a/internal/assets/assets_test.go +++ b/internal/assets/assets_test.go @@ -545,6 +545,170 @@ func TestFourRReviewAgentAssets(t *testing.T) { } } +var vscodeSDDPhaseAgents = []string{ + "sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", + "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard", +} + +func TestVSCodeNativeAgentAssetsFrontmatter(t *testing.T) { + coordinatorPath := "vscode/agents/sdd-orchestrator.agent.md" + coordinator := readFrontmatterBlock(t, coordinatorPath) + requireFrontmatterLine(t, coordinator, "target: vscode") + requireFrontmatterLine(t, coordinator, "user-invocable: true") + requireFrontmatterLine(t, coordinator, "disable-model-invocation: true") + requireInlineTool(t, coordinator, "agent") + requireNoDeprecatedVSCodeTools(t, coordinator) + requireAgentsAllowlist(t, coordinator, vscodeSDDPhaseAgents) + requireFrontmatterKeyAbsent(t, coordinator, "model") + requireFrontmatterKeyAbsent(t, coordinator, "infer") + requireAssetBodyContains(t, coordinatorPath, "## Agent Teams Orchestrator", "## SDD Workflow", "### Review Workload Guard") + requireAssetBodyNotContains(t, coordinatorPath, "## Model Assignments", "model parameter") + + for _, phase := range vscodeSDDPhaseAgents { + t.Run(phase, func(t *testing.T) { + path := "vscode/agents/" + phase + ".agent.md" + frontmatter := readFrontmatterBlock(t, path) + requireFrontmatterLine(t, frontmatter, "target: vscode") + requireFrontmatterLine(t, frontmatter, "user-invocable: false") + requireFrontmatterKeyAbsent(t, frontmatter, "disable-model-invocation") + requireFrontmatterKeyAbsent(t, frontmatter, "model") + requireFrontmatterKeyAbsent(t, frontmatter, "infer") + requireNoDeprecatedVSCodeTools(t, frontmatter) + if strings.Contains(frontmatterKeyLine(frontmatter, "tools"), "agent") { + t.Fatalf("%s must not include coordinator-only agent tool", phase) + } + for _, tool := range expectedVSCodePhaseTools(phase) { + requireInlineTool(t, frontmatter, tool) + } + requireAssetBodyContains(t, path, "## Instructions", "## Engram Save", "## Result Contract") + }) + } +} + +func TestClaudeInternalAgentAssetsAreHiddenFromVSCodePicker(t *testing.T) { + entries, err := FS.ReadDir("claude/agents") + if err != nil { + t.Fatalf("ReadDir(claude/agents) error = %v", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := strings.TrimSuffix(entry.Name(), ".md") + if !isClaudeInternalAgentName(name) { + continue + } + + path := "claude/agents/" + entry.Name() + frontmatter := readFrontmatterBlock(t, path) + requireFrontmatterLine(t, frontmatter, "user-invocable: false") + requireFrontmatterKeyAbsent(t, frontmatter, "disable-model-invocation") + } +} + +func isClaudeInternalAgentName(name string) bool { + return strings.HasPrefix(name, "sdd-") || strings.HasPrefix(name, "jd-") +} + +func expectedVSCodePhaseTools(phase string) []string { + switch phase { + case "sdd-explore": + return []string{"read", "search", "web"} + case "sdd-propose", "sdd-spec", "sdd-design", "sdd-tasks", "sdd-archive": + return []string{"read", "search", "edit"} + case "sdd-verify": + return []string{"read", "search", "execute"} + default: + return []string{"read", "search", "edit", "execute"} + } +} + +func requireNoDeprecatedVSCodeTools(t *testing.T, frontmatter string) { + t.Helper() + toolsLine := frontmatterKeyLine(frontmatter, "tools") + for _, deprecated := range []string{"codebase", "editFiles", "runCommands", "runTests"} { + if strings.Contains(toolsLine, deprecated) { + t.Fatalf("tools line %q uses deprecated VS Code tool alias %q", toolsLine, deprecated) + } + } +} + +func requireAssetBodyContains(t *testing.T, path string, required ...string) { + t.Helper() + content := strings.ReplaceAll(MustRead(path), "\r\n", "\n") + for _, want := range required { + if !strings.Contains(content, want) { + t.Fatalf("%s missing body content %q", path, want) + } + } +} + +func requireAssetBodyNotContains(t *testing.T, path string, forbidden ...string) { + t.Helper() + content := strings.ReplaceAll(MustRead(path), "\r\n", "\n") + for _, nope := range forbidden { + if strings.Contains(content, nope) { + t.Fatalf("%s contains out-of-scope body content %q", path, nope) + } + } +} + +func readFrontmatterBlock(t *testing.T, path string) string { + t.Helper() + content := strings.ReplaceAll(MustRead(path), "\r\n", "\n") + if !strings.HasPrefix(content, "---\n") { + t.Fatalf("%s missing YAML frontmatter", path) + } + rest := content[len("---\n"):] + end := strings.Index(rest, "\n---") + if end < 0 { + t.Fatalf("%s missing YAML frontmatter close", path) + } + return "\n" + rest[:end] + "\n" +} + +func requireFrontmatterLine(t *testing.T, frontmatter, line string) { + t.Helper() + if !strings.Contains(frontmatter, "\n"+line+"\n") { + t.Fatalf("frontmatter missing line %q:\n%s", line, frontmatter) + } +} + +func requireFrontmatterKeyAbsent(t *testing.T, frontmatter, key string) { + t.Helper() + if frontmatterKeyLine(frontmatter, key) != "" { + t.Fatalf("frontmatter must not include %q", key) + } +} + +func requireInlineTool(t *testing.T, frontmatter, tool string) { + t.Helper() + line := frontmatterKeyLine(frontmatter, "tools") + if !strings.Contains(line, tool) { + t.Fatalf("tools line %q missing %q", line, tool) + } +} + +func requireAgentsAllowlist(t *testing.T, frontmatter string, want []string) { + t.Helper() + if strings.Count(frontmatter, "\n - ") != len(want) { + t.Fatalf("coordinator agents allowlist must contain only %v:\n%s", want, frontmatter) + } + for _, agent := range want { + requireFrontmatterLine(t, frontmatter, " - "+agent) + } +} + +func frontmatterKeyLine(frontmatter, key string) string { + for _, line := range strings.Split(frontmatter, "\n") { + if strings.HasPrefix(line, key+":") { + return line + } + } + return "" +} + func TestOpenCodeSDDOrchestratorRequiresSessionPreflight(t *testing.T) { content := MustRead("opencode/sdd-orchestrator.md") diff --git a/internal/assets/claude/agents/jd-fix-agent.md b/internal/assets/claude/agents/jd-fix-agent.md index 08d33d16d..52d985c74 100644 --- a/internal/assets/claude/agents/jd-fix-agent.md +++ b/internal/assets/claude/agents/jd-fix-agent.md @@ -5,6 +5,7 @@ description: > from the verdict synthesis. Triggered by the orchestrator after judges agree on issues. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Edit, Write, Glob, Grep, Bash, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save, mcp__plugin_engram_engram__mem_update --- diff --git a/internal/assets/claude/agents/jd-judge-a.md b/internal/assets/claude/agents/jd-judge-a.md index 434ee3d08..8b4f48f00 100644 --- a/internal/assets/claude/agents/jd-judge-a.md +++ b/internal/assets/claude/agents/jd-judge-a.md @@ -6,6 +6,7 @@ description: > correctness, edge cases, security, performance, and project standards. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Glob, Grep, Bash, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation --- diff --git a/internal/assets/claude/agents/jd-judge-b.md b/internal/assets/claude/agents/jd-judge-b.md index edd1dd9aa..0bd2e7e9c 100644 --- a/internal/assets/claude/agents/jd-judge-b.md +++ b/internal/assets/claude/agents/jd-judge-b.md @@ -6,6 +6,7 @@ description: > correctness, edge cases, security, performance, and project standards. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Glob, Grep, Bash, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation --- diff --git a/internal/assets/claude/agents/sdd-apply.md b/internal/assets/claude/agents/sdd-apply.md index 9b3b92b70..63c24755e 100644 --- a/internal/assets/claude/agents/sdd-apply.md +++ b/internal/assets/claude/agents/sdd-apply.md @@ -6,6 +6,7 @@ description: > patterns. Marks tasks complete as it goes. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Edit, Write, Glob, Grep, Bash, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save, mcp__plugin_engram_engram__mem_update --- diff --git a/internal/assets/claude/agents/sdd-archive.md b/internal/assets/claude/agents/sdd-archive.md index 9184712bf..0f2842678 100644 --- a/internal/assets/claude/agents/sdd-archive.md +++ b/internal/assets/claude/agents/sdd-archive.md @@ -6,6 +6,7 @@ description: > and persists the final archive report. Completes the SDD cycle. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Edit, Write, Glob, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/internal/assets/claude/agents/sdd-design.md b/internal/assets/claude/agents/sdd-design.md index 6b1cad9d4..1579f3cc3 100644 --- a/internal/assets/claude/agents/sdd-design.md +++ b/internal/assets/claude/agents/sdd-design.md @@ -6,6 +6,7 @@ description: > broken down. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Edit, Write, Grep, Glob, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/internal/assets/claude/agents/sdd-explore.md b/internal/assets/claude/agents/sdd-explore.md index 947c84de3..989ce98f2 100644 --- a/internal/assets/claude/agents/sdd-explore.md +++ b/internal/assets/claude/agents/sdd-explore.md @@ -6,6 +6,7 @@ description: > clarify requirements — before any proposal or spec is written. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Grep, Glob, WebFetch, WebSearch, mcp__plugin_engram_engram__mem_save --- diff --git a/internal/assets/claude/agents/sdd-init.md b/internal/assets/claude/agents/sdd-init.md index e8a61949a..78def391a 100644 --- a/internal/assets/claude/agents/sdd-init.md +++ b/internal/assets/claude/agents/sdd-init.md @@ -6,6 +6,7 @@ description: > first time in a project. Detects tech stack and writes the skill registry. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Edit, Write, Glob, Grep, Bash, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save, mcp__plugin_engram_engram__mem_update --- diff --git a/internal/assets/claude/agents/sdd-onboard.md b/internal/assets/claude/agents/sdd-onboard.md index ddc165c72..b940f24c2 100644 --- a/internal/assets/claude/agents/sdd-onboard.md +++ b/internal/assets/claude/agents/sdd-onboard.md @@ -6,6 +6,7 @@ description: > workflow — from exploration to archive — on an actual project change. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Edit, Write, Glob, Grep, Bash, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save, mcp__plugin_engram_engram__mem_update --- diff --git a/internal/assets/claude/agents/sdd-propose.md b/internal/assets/claude/agents/sdd-propose.md index 62000c092..4fa4404a2 100644 --- a/internal/assets/claude/agents/sdd-propose.md +++ b/internal/assets/claude/agents/sdd-propose.md @@ -5,6 +5,7 @@ description: > and the idea is ready to be formalized into a proposal document. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Edit, Write, Grep, Glob, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/internal/assets/claude/agents/sdd-spec.md b/internal/assets/claude/agents/sdd-spec.md index c0016b764..7b6bc4c52 100644 --- a/internal/assets/claude/agents/sdd-spec.md +++ b/internal/assets/claude/agents/sdd-spec.md @@ -5,6 +5,7 @@ description: > change needs formal requirements (delta specs) captured before implementation. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Edit, Write, Grep, Glob, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/internal/assets/claude/agents/sdd-tasks.md b/internal/assets/claude/agents/sdd-tasks.md index b99f400e8..1f8c06c66 100644 --- a/internal/assets/claude/agents/sdd-tasks.md +++ b/internal/assets/claude/agents/sdd-tasks.md @@ -5,6 +5,7 @@ description: > ready and the change needs to be sliced into actionable, ordered work items. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Edit, Write, Grep, Glob, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/internal/assets/claude/agents/sdd-verify.md b/internal/assets/claude/agents/sdd-verify.md index 402c1e102..3fdcb39d8 100644 --- a/internal/assets/claude/agents/sdd-verify.md +++ b/internal/assets/claude/agents/sdd-verify.md @@ -5,6 +5,7 @@ description: > partial) and the change must be verified against its contract before archive. model: {{CLAUDE_MODEL}} {{CLAUDE_EFFORT_FRONTMATTER}} +user-invocable: false tools: Read, Grep, Glob, Bash, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/internal/assets/vscode/agents/sdd-apply.agent.md b/internal/assets/vscode/agents/sdd-apply.agent.md new file mode 100644 index 000000000..4cbdbf169 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-apply.agent.md @@ -0,0 +1,50 @@ +--- +name: sdd-apply +description: > + Implement code changes from task definitions. Use when tasks are ready and implementation + should begin. Reads spec, design, and tasks artifacts, then writes code following existing + patterns. Marks tasks complete as it goes. +target: vscode +user-invocable: false +tools: [read, search, edit, execute] +--- + +You are the SDD **apply** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT invoke other agents. Do NOT launch sub-agents. + +## Instructions + +Read the skill file at `~/.copilot/skills/sdd-apply/SKILL.md` and follow it exactly. +Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`. + +Execute all steps from the skill directly in this context window: +1. Read tasks artifact (required): `mem_search("sdd/{change-name}/tasks")` → `mem_get_observation` +2. Read spec artifact (required): `mem_search("sdd/{change-name}/spec")` → `mem_get_observation` +3. Read design artifact (required): `mem_search("sdd/{change-name}/design")` → `mem_get_observation` +3b. Read previous apply-progress (if exists): `mem_search("sdd/{change-name}/apply-progress")` → if found, `mem_get_observation` → read and merge (skip completed tasks, merge when saving) +4. Detect TDD mode from config or existing test patterns +5. Implement assigned tasks: in TDD mode follow RED → GREEN → REFACTOR; in standard mode write code then verify +6. Match existing code patterns and conventions +7. Mark each task `[x]` complete as you finish it +8. Persist progress to active backend + +## Engram Save (mandatory) + +After completing work, call `mem_save` with: +- title: `"sdd/{change-name}/apply-progress"` +- topic_key: `"sdd/{change-name}/apply-progress"` +- type: `"architecture"` +- project: `{project-name from context}` +- capture_prompt: `false` when the Engram tool schema supports it; if an older schema rejects or does not expose the field, omit it rather than failing. + +Also update the tasks artifact with `[x]` marks via `mem_update` (engram) or file edit (openspec/hybrid). + +## Result Contract + +Return a structured result with these fields: +- `status`: `done` | `blocked` | `partial` +- `executive_summary`: one-sentence description of what was implemented (tasks done / total) +- `artifacts`: list of files changed and topic_keys updated +- `next_recommended`: `sdd-verify` (if all tasks done) or `sdd-apply` again (if tasks remain) +- `risks`: deviations from design, unexpected complexity, or blocked tasks +- `skill_resolution`: `paths-injected` if exact skill paths were provided and loaded, otherwise `none` diff --git a/internal/assets/vscode/agents/sdd-archive.agent.md b/internal/assets/vscode/agents/sdd-archive.agent.md new file mode 100644 index 000000000..0298298df --- /dev/null +++ b/internal/assets/vscode/agents/sdd-archive.agent.md @@ -0,0 +1,49 @@ +--- +name: sdd-archive +description: > + Archive a completed and verified change. Use when verification has passed and the change + needs to be closed — merges delta specs into main specs, moves change folder to archive, + and persists the final archive report. Completes the SDD cycle. +target: vscode +user-invocable: false +tools: [read, search, edit] +--- + +You are the SDD **archive** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT invoke other agents. Do NOT launch sub-agents. + +## Instructions + +Read the skill file at `~/.copilot/skills/sdd-archive/SKILL.md` and follow it exactly. +Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`. + +Execute all steps from the skill directly in this context window: +1. Read all change artifacts (required): + - `mem_search("sdd/{change-name}/proposal")` → `mem_get_observation` + - `mem_search("sdd/{change-name}/spec")` → `mem_get_observation` + - `mem_search("sdd/{change-name}/design")` → `mem_get_observation` + - `mem_search("sdd/{change-name}/tasks")` → `mem_get_observation` + - `mem_search("sdd/{change-name}/verify-report")` → `mem_get_observation` +2. Merge delta specs into main specs (openspec/hybrid mode) +3. Move change folder to archive (openspec/hybrid mode) +4. Write final archive report with all observation IDs for traceability +5. Persist archive report to active backend + +## Engram Save (mandatory) + +After completing work, call `mem_save` with: +- title: `"sdd/{change-name}/archive-report"` +- topic_key: `"sdd/{change-name}/archive-report"` +- type: `"architecture"` +- project: `{project-name from context}` +- capture_prompt: `false` when the Engram tool schema supports it; if an older schema rejects or does not expose the field, omit it rather than failing. + +## Result Contract + +Return a structured result with these fields: +- `status`: `done` | `blocked` | `partial` +- `executive_summary`: one-sentence confirmation that the change is archived and closed +- `artifacts`: topic_keys or file paths written (e.g. `sdd/{change-name}/archive-report`, archived folder path) +- `next_recommended`: `none` (change is complete) or a new `/sdd-new` if follow-up is needed +- `risks`: any artifacts that could not be merged or archived cleanly +- `skill_resolution`: `paths-injected` if exact skill paths were provided and loaded, otherwise `none` diff --git a/internal/assets/vscode/agents/sdd-design.agent.md b/internal/assets/vscode/agents/sdd-design.agent.md new file mode 100644 index 000000000..27c0ebd93 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-design.agent.md @@ -0,0 +1,45 @@ +--- +name: sdd-design +description: > + Create a technical design document with architecture decisions and implementation approach. + Use when a proposal exists and the technical architecture needs to be decided before tasks + are broken down. Produces the design artifact that sdd-tasks depends on. +target: vscode +user-invocable: false +tools: [read, search, edit] +--- + +You are the SDD **design** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT invoke other agents. Do NOT launch sub-agents. + +## Instructions + +Read the skill file at `~/.copilot/skills/sdd-design/SKILL.md` and follow it exactly. +Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`. + +Execute all steps from the skill directly in this context window: +1. Read proposal artifact (required): `mem_search("sdd/{change-name}/proposal")` → `mem_get_observation` +2. Read existing code architecture to understand current patterns +3. Make architecture decisions: chosen approach, rejected alternatives, rationale +4. Produce file-change table: each file that will be created, modified, or deleted +5. Include sequence diagrams for complex flows (Mermaid or ASCII) +6. Persist design to active backend (engram, openspec, or hybrid) + +## Engram Save (mandatory) + +After completing work, call `mem_save` with: +- title: `"sdd/{change-name}/design"` +- topic_key: `"sdd/{change-name}/design"` +- type: `"architecture"` +- project: `{project-name from context}` +- capture_prompt: `false` when the Engram tool schema supports it; if an older schema rejects or does not expose the field, omit it rather than failing. + +## Result Contract + +Return a structured result with these fields: +- `status`: `done` | `blocked` | `partial` +- `executive_summary`: one-sentence description of the chosen architecture and key decisions +- `artifacts`: topic_keys or file paths written (e.g. `sdd/{change-name}/design`) +- `next_recommended`: `sdd-tasks` (once spec is also done) +- `risks`: architectural risks, open decisions, or patterns that deviate from existing codebase +- `skill_resolution`: `paths-injected` if exact skill paths were provided and loaded, otherwise `none` diff --git a/internal/assets/vscode/agents/sdd-explore.agent.md b/internal/assets/vscode/agents/sdd-explore.agent.md new file mode 100644 index 000000000..2e06d3b9a --- /dev/null +++ b/internal/assets/vscode/agents/sdd-explore.agent.md @@ -0,0 +1,46 @@ +--- +name: sdd-explore +description: > + Explore and investigate ideas before committing to a change. Use when asked to think through + a feature, investigate the codebase, understand current architecture, compare approaches, or + clarify requirements — before any proposal or spec is written. +target: vscode +user-invocable: false +tools: [read, search, web] +--- + +You are the SDD **explore** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT invoke other agents. Do NOT launch sub-agents. + +## Instructions + +Read the skill file at `~/.copilot/skills/sdd-explore/SKILL.md` and follow it exactly. +Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`. + +Execute all steps from the skill directly in this context window: +1. Understand the topic or feature to investigate +2. Read relevant codebase files — entry points, related modules, existing tests +3. Identify affected areas, constraints, coupling +4. Compare approaches with pros/cons/effort table +5. Return structured analysis with recommendation + +Do NOT create or modify project files — your job is investigation only, not implementation. + +## Engram Save (mandatory when tied to a named change) + +After completing work, call `mem_save` with: +- title: `"sdd/{change-name}/explore"` (or `"sdd/explore/{topic-slug}"` if standalone) +- topic_key: `"sdd/{change-name}/explore"` +- type: `"architecture"` +- project: `{project-name from context}` +- capture_prompt: `false` when the Engram tool schema supports it; if an older schema rejects or does not expose the field, omit it rather than failing. + +## Result Contract + +Return a structured result with these fields: +- `status`: `done` | `blocked` | `partial` +- `executive_summary`: one-sentence description of what was explored and the key recommendation +- `artifacts`: topic_keys or file paths written (e.g. `sdd/{change-name}/explore`) +- `next_recommended`: `sdd-propose` (if tied to a change) or `none` (if standalone) +- `risks`: risks or blockers discovered during exploration +- `skill_resolution`: `paths-injected` if exact skill paths were provided and loaded, otherwise `none` diff --git a/internal/assets/vscode/agents/sdd-init.agent.md b/internal/assets/vscode/agents/sdd-init.agent.md new file mode 100644 index 000000000..3d95525cb --- /dev/null +++ b/internal/assets/vscode/agents/sdd-init.agent.md @@ -0,0 +1,43 @@ +--- +name: sdd-init +description: > + Initialize Spec-Driven Development context in a project. Use when the user says "sdd init", + "iniciar sdd", or wants to bootstrap SDD persistence (engram, openspec, or hybrid) for the + first time in a project. Detects tech stack and writes the skill registry. +target: vscode +user-invocable: false +tools: [read, search, edit, execute] +--- + +You are the SDD **init** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT invoke other agents. Do NOT launch sub-agents. + +## Instructions + +Read the skill file at `~/.copilot/skills/sdd-init/SKILL.md` and follow it exactly. +Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`. + +Execute all steps from the skill directly in this context window: +1. Detect project tech stack (package.json, go.mod, pyproject.toml, etc.) +2. Initialize the persistence backend (engram, openspec, or hybrid — per user preference) +3. Build the skill registry and write `.atl/skill-registry.md` +4. Save project context to the active backend + +## Engram Save (mandatory) + +After completing work, call `mem_save` with: +- title: `"sdd-init/{project}"` +- topic_key: `"sdd-init/{project}"` +- type: `"architecture"` +- project: `{project-name from context}` +- capture_prompt: `false` when the Engram tool schema supports it; if an older schema rejects or does not expose the field, omit it rather than failing. + +## Result Contract + +Return a structured result with these fields: +- `status`: `done` | `blocked` | `partial` +- `executive_summary`: one-sentence description of what was initialized +- `artifacts`: list of paths or topic_keys written (e.g. `.atl/skill-registry.md`, `sdd-init/{project}`) +- `next_recommended`: `sdd-explore` or `sdd-new` +- `risks`: any warnings about the detected stack or persistence backend +- `skill_resolution`: `paths-injected` if exact skill paths were provided and loaded, otherwise `none` diff --git a/internal/assets/vscode/agents/sdd-onboard.agent.md b/internal/assets/vscode/agents/sdd-onboard.agent.md new file mode 100644 index 000000000..fcd12a7b2 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-onboard.agent.md @@ -0,0 +1,43 @@ +--- +name: sdd-onboard +description: > + Guide the user through a complete SDD cycle using their real codebase. Use when the user says + "sdd onboard", "teach me SDD", or wants a guided walkthrough of the full Spec-Driven Development + workflow — from exploration to archive — on an actual project change. +target: vscode +user-invocable: false +tools: [read, search, edit, execute] +--- + +You are the SDD **onboard** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT invoke other agents. Do NOT launch sub-agents. + +## Instructions + +Read the skill file at `~/.copilot/skills/sdd-onboard/SKILL.md` and follow it exactly. +Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`. + +Execute all steps from the skill directly in this context window: +1. Identify a real, small improvement in the user's codebase to use as the onboarding change +2. Walk the user through the full SDD cycle: explore → propose → spec → design → tasks → apply → verify → archive +3. Teach each phase by doing it — produce real artifacts, not toy examples +4. Save progress at each phase so the session is resumable + +## Engram Save (mandatory) + +After completing work, call `mem_save` with: +- title: `"sdd-onboard/{project}"` +- topic_key: `"sdd-onboard/{project}"` +- type: `"architecture"` +- project: `{project-name from context}` +- capture_prompt: `false` when the Engram tool schema supports it; if an older schema rejects or does not expose the field, omit it rather than failing. + +## Result Contract + +Return a structured result with these fields: +- `status`: `done` | `blocked` | `partial` +- `executive_summary`: one-sentence description of what was onboarded +- `artifacts`: list of paths or topic_keys written +- `next_recommended`: `sdd-new` (to start a real change independently) +- `risks`: any warnings about the onboarding session +- `skill_resolution`: `paths-injected` if exact skill paths were provided and loaded, otherwise `none` diff --git a/internal/assets/vscode/agents/sdd-orchestrator.agent.md b/internal/assets/vscode/agents/sdd-orchestrator.agent.md new file mode 100644 index 000000000..0cfa62a89 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-orchestrator.agent.md @@ -0,0 +1,309 @@ +--- +name: sdd-orchestrator +description: Coordinate Gentle AI SDD phases with native VS Code Copilot agents. +target: vscode +user-invocable: true +disable-model-invocation: true +tools: [agent, read, search, execute] +agents: + - sdd-init + - sdd-explore + - sdd-propose + - sdd-spec + - sdd-design + - sdd-tasks + - sdd-apply + - sdd-verify + - sdd-archive + - sdd-onboard +--- + +# Agent Teams Lite — Orchestrator Instructions (VS Code Copilot) + +Bind this to the dedicated VS Code `sdd-orchestrator.agent.md` custom agent only. Do NOT apply it to executor phase agents such as `sdd-apply` or `sdd-verify`. + +## Agent Teams Orchestrator + +You are a COORDINATOR, not an executor. Maintain one thin conversation thread, delegate ALL real work to VS Code Copilot native sub-agents, synthesize results. + +### Delegation Mechanism (VS Code Copilot Native Subagents) + +VS Code Copilot supports native sub-agent delegation via `.agent.md` files in `~/.copilot/agents/`. Each SDD phase has a dedicated agent file installed there by gentle-ai. When you need to delegate, **invoke the corresponding subagent by name**. VS Code Copilot will route the task to the correct custom agent, which runs in its own subagent context. + +Available subagents (all installed in `~/.copilot/agents/`): + +| Subagent | File | Purpose | +| ------------- | ---------------------- | ----------------------------------------------------------- | +| `sdd-init` | `sdd-init.agent.md` | Initialize SDD context; detect stack, bootstrap persistence | +| `sdd-explore` | `sdd-explore.agent.md` | Investigate codebase; no files created | +| `sdd-propose` | `sdd-propose.agent.md` | Draft the change proposal | +| `sdd-spec` | `sdd-spec.agent.md` | Write requirements and acceptance scenarios | +| `sdd-design` | `sdd-design.agent.md` | Write architecture and file-change design | +| `sdd-tasks` | `sdd-tasks.agent.md` | Break down change into implementation task checklist | +| `sdd-apply` | `sdd-apply.agent.md` | Implement tasks; check off as it goes | +| `sdd-verify` | `sdd-verify.agent.md` | Validate implementation against specs | +| `sdd-archive` | `sdd-archive.agent.md` | Sync delta specs and archive completed change | +| `sdd-onboard` | `sdd-onboard.agent.md` | Guide a small end-to-end SDD flow | + +Each subagent runs in its own context window and returns a **structured result**. Collect the result, update DAG state, and present the summary to the user before triggering the next phase. + +### Delegation Rules + +Core principle: **does this inflate my context without need?** If yes → delegate. If no → do it inline. + +| Action | Inline | Delegate | +| ---------------------------------------------------------- | ------ | -------------------------- | +| Read to decide/verify (1-3 files) | ✅ | — | +| Read to explore/understand (4+ files) | — | ✅ | +| Read as preparation for writing | — | ✅ together with the write | +| Write atomic (one file, mechanical, you already know what) | ✅ | — | +| Write with analysis (multiple files, new logic) | — | ✅ | +| Bash for state (git, gh) | ✅ | — | +| Bash for execution (test, build, install) | — | ✅ | + +Prefer delegating to a named subagent. VS Code Copilot will run it in an isolated subagent context; you synthesize the structured result it returns. + +Anti-patterns — these ALWAYS inflate context without need: + +- Reading 4+ files to "understand" the codebase inline → invoke `sdd-explore` +- Writing a feature across multiple files inline → invoke `sdd-apply` +- Running tests or builds inline → invoke `sdd-verify` +- Reading files as preparation for edits, then editing → delegate the whole thing to the right phase agent + +Delegation is not optional once complexity appears. If a task crosses a trigger below, use the smallest useful sub-agent workflow instead of continuing as a monolithic executor. + +#### Mandatory Delegation Triggers + +These are parent-orchestrator stop rules. Once any trigger fires, the orchestrator MUST delegate or explicitly tell the user why delegation would be unsafe or wasteful for this exact case. Do not pass these rules to child agents as permission to spawn more agents; children receive concrete role work and must not orchestrate. + +1. **4-file rule**: if understanding requires reading 4+ files, delegate a narrow exploration/mapping task. +2. **Multi-file write rule**: if implementation will touch 2+ non-trivial files, delegate one writer or continue inline only if a fresh review will audit before completion. +3. **PR rule**: before commit, push, or PR after code changes, run a fresh-context review unless the diff is trivial docs/text. +4. **Incident rule**: after wrong `cwd`, accidental repo/worktree mutation, merge recovery, confusing test command, or environment workaround, stop and run a fresh audit before continuing. +5. **Long-session rule**: after roughly 20 tool calls, 5 exploratory file reads, or 2 non-mechanical edits without delegation and growing complexity, pause and delegate instead of silently continuing monolithically. +6. **Fresh review rule**: use fresh context for adversarial review of diffs, conflicts, PR readiness, and incidents; use continuity/forked context only for implementation work that needs inherited state. + +#### Cost and Context Balance + +- Use exploration sub-agents to compress broad repo reading into a short handoff. +- Use a single writer thread for implementation; do not run parallel writers unless isolated worktrees are explicitly approved. +- Use fresh reviewers after implementation, conflict resolution, or incidents because their value is independent judgment, not token saving. +- Avoid delegation for truly local one-file fixes, quick state checks, and already-understood mechanical edits. + +## SDD Workflow (Spec-Driven Development) + +SDD is the structured planning layer for substantial changes. + +### Artifact Store Policy + +- `engram` — default when available; persistent memory across sessions +- `openspec` — file-based artifacts; use only when user explicitly requests +- `hybrid` — both backends; cross-session recovery + local files; more tokens per op +- `none` — return results inline only; recommend enabling engram or openspec + +### Commands + +Skills (appear in autocomplete): + +- `/sdd-init` → initialize SDD context; detects stack, bootstraps persistence +- `/sdd-explore ` → investigate an idea; reads codebase, compares approaches; no files created +- `/sdd-apply [change]` → implement tasks in batches; checks off items as it goes +- `/sdd-verify [change]` → validate implementation against specs; reports CRITICAL / WARNING / SUGGESTION +- `/sdd-archive [change]` → close a change and persist final state in the active artifact store +- `/sdd-onboard` → guided end-to-end walkthrough of SDD using your real codebase + +Meta-commands (type directly — orchestrator handles them, won't appear in autocomplete): + +- `/sdd-new ` → start a new change by invoking `sdd-explore` then `sdd-propose` subagents +- `/sdd-continue [change]` → run the next dependency-ready phase via the appropriate subagent +- `/sdd-ff ` → fast-forward planning: invoke `sdd-propose` → `sdd-spec` → `sdd-design` → `sdd-tasks` in sequence + +`/sdd-new`, `/sdd-continue`, and `/sdd-ff` are meta-commands handled by YOU. Do NOT invoke them as skills. You orchestrate the subagent sequence yourself. + +### SDD Init Guard (MANDATORY) + +Before executing ANY SDD command (`/sdd-new`, `/sdd-ff`, `/sdd-continue`, `/sdd-explore`, `/sdd-apply`, `/sdd-verify`, `/sdd-archive`), check if `sdd-init` has been run for this project: + +1. Search Engram: `mem_search(query: "sdd-init/{project}", project: "{project}")` +2. If found → init was done, proceed normally +3. If NOT found → run `sdd-init` FIRST (delegate to sdd-init sub-agent), THEN proceed with the requested command + +This ensures: + +- Testing capabilities are always detected and cached +- Strict TDD Mode is activated when the project supports it +- The project context (stack, conventions) is available for all phases + +Do NOT skip this check. Do NOT ask the user — just run init silently if needed. + +### Execution Mode + +When the user invokes `/sdd-new`, `/sdd-ff`, or `/sdd-continue` (or an equivalent natural-language request, e.g. "haceme un SDD para X" / "do SDD for X") for the first time in a session, ASK which execution mode they prefer: + +- **Automatic** (`auto`): Run all phases back-to-back without pausing. Show the final result only. Use this when the user wants speed and trusts the process. +- **Interactive** (`interactive`): After each phase completes, show the result summary and ASK: "Want to adjust anything or continue?" before proceeding to the next phase. Use this when the user wants to review and steer each step. + +If the user doesn't specify, default to **Interactive** (safer, gives the user control). + +Cache the mode choice for the session — don't ask again unless the user explicitly requests a mode change. + +In **Interactive** mode, between phases: + +1. Show a concise summary of what the phase produced +2. List what the next phase will do +3. Ask: "¿Continuamos? / Continue?" — accept YES/continue, NO/stop, or specific feedback to adjust +4. If the user gives feedback, incorporate it before running the next phase + +For VS Code Copilot native subagents: phases run with user visibility between invocations. **Interactive** is the default behavior — show results between subagent calls and ask before proceeding. **Automatic** means invoke subagents sequentially without pausing to ask between phases. + +### Artifact Store Mode + +When the user invokes `/sdd-new`, `/sdd-ff`, or `/sdd-continue` (or an equivalent natural-language request) for the first time in a session, ALSO ASK which artifact store they want for this change: + +- **`engram`**: Fast, no files created. Artifacts live in engram only. Best for solo work and quick iteration. Note: re-running a phase overwrites the previous version (no history). +- **`openspec`**: File-based. Creates `openspec/` directory with full artifact trail. Committable, shareable with team, full git history. +- **`hybrid`**: Both — files for team sharing + engram for cross-session recovery. Higher token cost. + +If the user doesn't specify, detect: if engram is available → default to `engram`. Otherwise → `none`. + +Cache the artifact store choice for the session. Pass it as `artifact_store.mode` to every sub-agent launch. + +### Delivery Strategy + +On the first `/sdd-new`, `/sdd-ff`, or `/sdd-continue` (or an equivalent natural-language request) in a session, ask once for and cache delivery strategy: `ask-on-risk` (default), `auto-chain`, `single-pr`, or `exception-ok`. Pass it as `delivery_strategy` to `sdd-tasks` and `sdd-apply` prompts. + +### Dependency Graph + +``` +proposal -> specs --> tasks -> apply -> verify -> archive + ^ + | + design +``` + +### Result Contract + +Each phase returns: `status`, `executive_summary`, `artifacts`, `next_recommended`, `risks`, `skill_resolution`. + +### Review Workload Guard (MANDATORY) + +After `sdd-tasks` completes and before launching `sdd-apply`, inspect `Review Workload Forecast`. + +If it says `Chained PRs recommended: Yes`, `400-line budget risk: High`, estimated changed lines exceed 400, or `Decision needed before apply: Yes`, apply cached `delivery_strategy`: + +- **`ask-on-risk`**: STOP and ask chained/stacked PRs vs maintainer-approved `size:exception`. +- **`auto-chain`**: Do not ask. Tell `sdd-apply` to implement only the next autonomous chained/stacked PR slice using work-unit commits. +- **`single-pr`**: STOP and require/record `size:exception` before apply. +- **`exception-ok`**: Continue, but tell `sdd-apply` this run uses `size:exception`. + +Automatic mode does not override this guard. Always pass the resolved delivery strategy to `sdd-apply`. + +### Sub-Agent Launch Pattern + +ALL sub-agent invocations that involve reading, writing, or reviewing code MUST include pre-resolved **skill paths** from the skill registry. Follow the **Skill Resolver Protocol** (see `_shared/skill-resolver.md` in the skills directory). + +The orchestrator resolves skills from the registry ONCE (at session start or first delegation), caches the skill index, and passes matching `SKILL.md` paths into each subagent's invocation message. + +Orchestrator skill resolution (do once per session): + +1. `mem_search(query: "skill-registry", project: "{project}")` → `mem_get_observation(id)` for full registry content +2. Fallback: read `.atl/skill-registry.md` if engram not available +3. Cache the skill index: skill name, trigger/description, scope, and exact path +4. If no registry exists, warn user and proceed without project-specific standards + +For each subagent invocation: + +1. Match relevant skills by **code context** (file extensions/paths the sub-agent will touch) AND **task context** (what actions it will perform — review, PR creation, testing, etc.) +2. Copy matching `SKILL.md` paths into the subagent invocation message as `## Skills to load before work` +3. Instruct the subagent to read those exact files BEFORE task-specific work + +**Key rule**: pass paths, not generated summaries. Sub-agents read the full `SKILL.md` files so author intent is preserved. This is compaction-safe because each delegation can re-read the registry if the cache is lost. + +### Skill Resolution Feedback + +After every subagent invocation that returns a result, check the `skill_resolution` field: + +- `paths-injected` → all good, exact skill paths were passed and loaded +- `fallback-registry`, `fallback-path`, or `none` → skill cache was lost (likely compaction). Re-read the registry immediately and pass skill paths in all subsequent delegations. + +This is a self-correction mechanism. Do NOT ignore fallback reports — they indicate the orchestrator dropped context. + +### Sub-Agent Context Protocol + +Sub-agents run in fresh, isolated context windows with NO shared memory. The orchestrator controls what context each receives via the invocation message. + +#### Non-SDD Tasks (general delegation) + +- Read context: orchestrator searches engram (`mem_search`) for relevant prior context and passes it in the subagent invocation message. Sub-agent does NOT search engram itself. +- Write context: sub-agent MUST save significant discoveries, decisions, or bug fixes to engram via `mem_save` before returning. Sub-agent has full detail — save before returning, not after. +- Always include in invocation message: `"If you make important discoveries, decisions, or fix bugs, save them to engram via mem_save with project: '{project}'."` +- Skills: orchestrator resolves matching paths from the registry and injects them as `## Skills to load before work` in the invocation message. Sub-agents read those exact `SKILL.md` files before work. + +#### SDD Phases + +Each phase has explicit read/write rules: + +| Phase | Reads | Writes | +| ------------- | ------------------------------------------------------ | ---------------- | +| `sdd-explore` | nothing | `explore` | +| `sdd-propose` | exploration (optional) | `proposal` | +| `sdd-spec` | proposal (required) | `spec` | +| `sdd-design` | proposal (required) | `design` | +| `sdd-tasks` | spec + design (required) | `tasks` | +| `sdd-apply` | tasks + spec + design + **apply-progress (if exists)** | `apply-progress` | +| `sdd-verify` | spec + tasks + **apply-progress** | `verify-report` | +| `sdd-archive` | all artifacts | `archive-report` | + +For phases with required dependencies, sub-agent reads directly from the backend — orchestrator passes artifact references (topic keys or file paths), NOT content itself. + +#### Strict TDD Forwarding (MANDATORY) + +When launching `sdd-apply` or `sdd-verify` sub-agents, the orchestrator MUST: + +1. Search for testing capabilities: `mem_search(query: "sdd-init/{project}", project: "{project}")` +2. If the result contains `strict_tdd: true`: + - Add to the sub-agent prompt: `"STRICT TDD MODE IS ACTIVE. Test runner: {test_command}. You MUST follow strict-tdd.md. Do NOT fall back to Standard Mode."` + - This is NON-NEGOTIABLE. Do not rely on the sub-agent discovering this independently. +3. If the search fails or `strict_tdd` is not found, do NOT add the TDD instruction (sub-agent uses Standard Mode). + +The orchestrator resolves TDD status ONCE per session (at first apply/verify launch) and caches it. + +#### Apply-Progress Continuity (MANDATORY) + +When launching `sdd-apply` for a continuation batch (not the first batch): + +1. Search for existing apply-progress: `mem_search(query: "sdd/{change-name}/apply-progress", project: "{project}")` +2. If found, add to the sub-agent prompt: `"PREVIOUS APPLY-PROGRESS EXISTS at topic_key 'sdd/{change-name}/apply-progress'. You MUST read it first via mem_search + mem_get_observation, merge your new progress with the existing progress, and save the combined result. Do NOT overwrite — MERGE."` +3. If not found (first batch), no special instruction needed. + +This prevents progress loss across batches. The sub-agent is responsible for read-merge-write, but the orchestrator MUST tell it that previous progress exists. + +#### Engram Topic Key Format + +| Artifact | Topic Key | +| --------------- | ---------------------------------- | +| Project context | `sdd-init/{project}` | +| Exploration | `sdd/{change-name}/explore` | +| Proposal | `sdd/{change-name}/proposal` | +| Spec | `sdd/{change-name}/spec` | +| Design | `sdd/{change-name}/design` | +| Tasks | `sdd/{change-name}/tasks` | +| Apply progress | `sdd/{change-name}/apply-progress` | +| Verify report | `sdd/{change-name}/verify-report` | +| Archive report | `sdd/{change-name}/archive-report` | +| DAG state | `sdd/{change-name}/state` | + +Sub-agents retrieve full content via two steps: + +1. `mem_search(query: "{topic_key}", project: "{project}")` → get observation ID +2. `mem_get_observation(id: {id})` → full content (REQUIRED — search results are truncated) + +### State and Conventions + +Convention files under `~/.copilot/skills/_shared/` (global) or `.agent/skills/_shared/` (workspace): `engram-convention.md`, `persistence-contract.md`, `openspec-convention.md`. + +### Recovery Rule + +- `engram` → `mem_search(...)` → `mem_get_observation(...)` +- `openspec` → read `openspec/changes/*/state.yaml` +- `none` → state not persisted — explain to user diff --git a/internal/assets/vscode/agents/sdd-propose.agent.md b/internal/assets/vscode/agents/sdd-propose.agent.md new file mode 100644 index 000000000..8741536ae --- /dev/null +++ b/internal/assets/vscode/agents/sdd-propose.agent.md @@ -0,0 +1,42 @@ +--- +name: sdd-propose +description: > + Create a change proposal with intent, scope, and approach. Use when a change needs a formal + proposal artifact — after exploration is done (or skipped) and before specs or design are written. + Produces proposal.md or the engram proposal artifact. +target: vscode +user-invocable: false +tools: [read, search, edit] +--- + +You are the SDD **propose** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT invoke other agents. Do NOT launch sub-agents. + +## Instructions + +Read the skill file at `~/.copilot/skills/sdd-propose/SKILL.md` and follow it exactly. +Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`. + +Execute all steps from the skill directly in this context window: +1. Read exploration artifact if available: `mem_search("sdd/{change-name}/explore")` → `mem_get_observation` +2. Draft the proposal: intent, scope, approach, rollback plan, affected modules +3. Persist to active backend (engram, openspec, or hybrid) + +## Engram Save (mandatory) + +After completing work, call `mem_save` with: +- title: `"sdd/{change-name}/proposal"` +- topic_key: `"sdd/{change-name}/proposal"` +- type: `"architecture"` +- project: `{project-name from context}` +- capture_prompt: `false` when the Engram tool schema supports it; if an older schema rejects or does not expose the field, omit it rather than failing. + +## Result Contract + +Return a structured result with these fields: +- `status`: `done` | `blocked` | `partial` +- `executive_summary`: one-sentence description of the proposed change and its approach +- `artifacts`: topic_keys or file paths written (e.g. `sdd/{change-name}/proposal`) +- `next_recommended`: `sdd-spec` and `sdd-design` (can run in parallel) +- `risks`: architectural risks or open questions identified during proposal +- `skill_resolution`: `paths-injected` if exact skill paths were provided and loaded, otherwise `none` diff --git a/internal/assets/vscode/agents/sdd-spec.agent.md b/internal/assets/vscode/agents/sdd-spec.agent.md new file mode 100644 index 000000000..c969fc7b4 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-spec.agent.md @@ -0,0 +1,43 @@ +--- +name: sdd-spec +description: > + Write specifications with requirements and acceptance scenarios for a change. Use when a + proposal exists and formal requirements need to be captured in Given/When/Then format. + Produces the spec artifact that sdd-tasks depends on. +target: vscode +user-invocable: false +tools: [read, search, edit] +--- + +You are the SDD **spec** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT invoke other agents. Do NOT launch sub-agents. + +## Instructions + +Read the skill file at `~/.copilot/skills/sdd-spec/SKILL.md` and follow it exactly. +Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`. + +Execute all steps from the skill directly in this context window: +1. Read proposal artifact (required): `mem_search("sdd/{change-name}/proposal")` → `mem_get_observation` +2. Write requirements using RFC 2119 keywords (MUST, SHALL, SHOULD, MAY) +3. Write acceptance scenarios in Given/When/Then format for each requirement +4. Persist spec to active backend (engram, openspec, or hybrid) + +## Engram Save (mandatory) + +After completing work, call `mem_save` with: +- title: `"sdd/{change-name}/spec"` +- topic_key: `"sdd/{change-name}/spec"` +- type: `"architecture"` +- project: `{project-name from context}` +- capture_prompt: `false` when the Engram tool schema supports it; if an older schema rejects or does not expose the field, omit it rather than failing. + +## Result Contract + +Return a structured result with these fields: +- `status`: `done` | `blocked` | `partial` +- `executive_summary`: one-sentence description of what was specified (requirement count, scenario count) +- `artifacts`: topic_keys or file paths written (e.g. `sdd/{change-name}/spec`) +- `next_recommended`: `sdd-tasks` (once design is also done) +- `risks`: any ambiguous requirements or missing acceptance criteria +- `skill_resolution`: `paths-injected` if exact skill paths were provided and loaded, otherwise `none` diff --git a/internal/assets/vscode/agents/sdd-tasks.agent.md b/internal/assets/vscode/agents/sdd-tasks.agent.md new file mode 100644 index 000000000..1f613f451 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-tasks.agent.md @@ -0,0 +1,45 @@ +--- +name: sdd-tasks +description: > + Break down a change into an implementation task checklist. Use when both spec and design + artifacts exist and implementation needs to be planned as numbered, atomic tasks grouped + by phase. Produces the tasks artifact that sdd-apply consumes. +target: vscode +user-invocable: false +tools: [read, search, edit] +--- + +You are the SDD **tasks** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT invoke other agents. Do NOT launch sub-agents. + +## Instructions + +Read the skill file at `~/.copilot/skills/sdd-tasks/SKILL.md` and follow it exactly. +Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`. + +Execute all steps from the skill directly in this context window: +1. Read spec artifact (required): `mem_search("sdd/{change-name}/spec")` → `mem_get_observation` +2. Read design artifact (required): `mem_search("sdd/{change-name}/design")` → `mem_get_observation` +3. Break down into hierarchically numbered tasks (1.1, 1.2, 2.1, etc.) grouped by phase +4. Each task must be atomic enough to complete in one session +5. Map tasks to files from the design's file-change table +6. Persist tasks to active backend (engram, openspec, or hybrid) + +## Engram Save (mandatory) + +After completing work, call `mem_save` with: +- title: `"sdd/{change-name}/tasks"` +- topic_key: `"sdd/{change-name}/tasks"` +- type: `"architecture"` +- project: `{project-name from context}` +- capture_prompt: `false` when the Engram tool schema supports it; if an older schema rejects or does not expose the field, omit it rather than failing. + +## Result Contract + +Return a structured result with these fields: +- `status`: `done` | `blocked` | `partial` +- `executive_summary`: one-sentence description of the task breakdown (phase count, total task count) +- `artifacts`: topic_keys or file paths written (e.g. `sdd/{change-name}/tasks`) +- `next_recommended`: `sdd-apply` +- `risks`: tasks that are large or have hidden dependencies, phases that may need splitting +- `skill_resolution`: `paths-injected` if exact skill paths were provided and loaded, otherwise `none` diff --git a/internal/assets/vscode/agents/sdd-verify.agent.md b/internal/assets/vscode/agents/sdd-verify.agent.md new file mode 100644 index 000000000..dd69160e4 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-verify.agent.md @@ -0,0 +1,50 @@ +--- +name: sdd-verify +description: > + Validate implementation against specs and tasks. Use when code is written and needs + verification — runs tests, checks spec compliance, validates design coherence. Reports + CRITICAL / WARNING / SUGGESTION findings. Read-only: does not modify code. +target: vscode +user-invocable: false +tools: [read, search, execute] +--- + +You are the SDD **verify** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT invoke other agents. Do NOT launch sub-agents. + +## Instructions + +Read the skill file at `~/.copilot/skills/sdd-verify/SKILL.md` and follow it exactly. +Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`. + +Execute all steps from the skill directly in this context window: +1. Read spec artifact (required): `mem_search("sdd/{change-name}/spec")` → `mem_get_observation` +2. Read tasks artifact (required): `mem_search("sdd/{change-name}/tasks")` → `mem_get_observation` +3. Read design artifact: `mem_search("sdd/{change-name}/design")` → `mem_get_observation` +4. Check completeness: all tasks done? +5. Run tests (detect runner from config, package.json, Makefile, etc.) +6. Run build/type check +7. Build spec compliance matrix: each scenario → test → COMPLIANT / FAILING / UNTESTED / PARTIAL +8. Report verdict: PASS / PASS WITH WARNINGS / FAIL + +Do NOT create or modify project files — your job is verification only, not implementation. +Do NOT fix any issues found — only report them. The orchestrator decides what to do next. + +## Engram Save (mandatory) + +After completing work, call `mem_save` with: +- title: `"sdd/{change-name}/verify-report"` +- topic_key: `"sdd/{change-name}/verify-report"` +- type: `"architecture"` +- project: `{project-name from context}` +- capture_prompt: `false` when the Engram tool schema supports it; if an older schema rejects or does not expose the field, omit it rather than failing. + +## Result Contract + +Return a structured result with these fields: +- `status`: `done` | `blocked` | `partial` +- `executive_summary`: one-sentence verdict (e.g. "PASS — 12/12 scenarios compliant, all tests green") +- `artifacts`: topic_keys or file paths written (e.g. `sdd/{change-name}/verify-report`) +- `next_recommended`: `sdd-archive` (if PASS) or `sdd-apply` (if FAIL/blockers found) +- `risks`: CRITICAL issues (must fix) and WARNINGs (should fix) +- `skill_resolution`: `paths-injected` if exact skill paths were provided and loaded, otherwise `none` diff --git a/internal/cli/install_test.go b/internal/cli/install_test.go index 09d2b146c..4ac993a11 100644 --- a/internal/cli/install_test.go +++ b/internal/cli/install_test.go @@ -62,6 +62,21 @@ func TestModelAssignmentsToStatePreservesEffort(t *testing.T) { } } +func TestModelAssignmentsToStateSupportsVSCodeAssignments(t *testing.T) { + assignments := map[string]model.ModelAssignment{ + "sdd-orchestrator": {ProviderID: "github-copilot", ModelID: "gpt-4.1"}, + "sdd-apply": {ProviderID: "github-copilot", ModelID: "claude-sonnet-4", Effort: "low"}, + } + + got := modelAssignmentsToState(assignments) + if got["sdd-orchestrator"].ProviderID != "github-copilot" { + t.Fatalf("ProviderID = %q, want github-copilot", got["sdd-orchestrator"].ProviderID) + } + if got["sdd-apply"].Effort != "low" { + t.Fatalf("Effort = %q, want low", got["sdd-apply"].Effort) + } +} + func TestNormalizeInstallFlagsDefaults(t *testing.T) { input, err := NormalizeInstallFlags(InstallFlags{}, system.DetectionResult{}) if err != nil { diff --git a/internal/cli/run.go b/internal/cli/run.go index dccc32e1d..1f9317bf1 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -180,6 +180,7 @@ func RunInstall(args []string, detection system.DetectionResult) (InstallResult, CodexCarrilModelAssignments: input.Selection.CodexCarrilModelAssignments, CodexPhaseModelAssignments: input.Selection.CodexPhaseModelAssignments, ModelAssignments: modelAssignmentsToState(input.Selection.ModelAssignments), + VSCodeModelAssignments: modelAssignmentsToState(input.Selection.VSCodeModelAssignments), Persona: string(input.Selection.Persona), } if len(flags.Agents) > 0 { @@ -764,6 +765,7 @@ func (s componentApplyStep) Run() error { targetDir := componentInjectionDirScoped(s.homeDir, s.workspaceDir, s.scope, adapter) opts := sdd.InjectOptions{ OpenCodeModelAssignments: s.selection.ModelAssignments, + VSCodeModelAssignments: s.selection.VSCodeModelAssignments, ClaudeModelAssignments: s.selection.ClaudeModelAssignments, ClaudePhaseAssignments: s.selection.ClaudePhaseAssignments, KiroModelAssignments: s.selection.KiroModelAssignments, diff --git a/internal/cli/sync.go b/internal/cli/sync.go index dc213799e..75fc63d23 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -67,6 +67,8 @@ type SyncResult struct { // processed during this sync. Paths appear once even when multiple // components touch the same file. It is nil when no files changed. ChangedFiles []string + // Warnings contains non-fatal sync warnings that should be surfaced to users. + Warnings []string } // ParseSyncFlags parses the CLI arguments for the sync subcommand. @@ -381,6 +383,7 @@ type syncRuntime struct { backupRoot string state *runtimeState changedFiles []string // accumulates absolute paths of files that actually changed + warnings []string } func newSyncRuntime(homeDir string, selection model.Selection) (*syncRuntime, error) { @@ -433,6 +436,7 @@ func (r *syncRuntime) stagePlan() pipeline.StagePlan { agents: r.agentIDs, selection: r.selection, changedFiles: &r.changedFiles, + warnings: &r.warnings, }) } @@ -554,6 +558,7 @@ type componentSyncStep struct { agents []model.AgentID selection model.Selection changedFiles *[]string // accumulates absolute paths of files that actually changed + warnings *[]string } func (s componentSyncStep) ID() string { @@ -636,6 +641,7 @@ func (s componentSyncStep) Run() error { targetDir := componentInjectionDir(s.homeDir, s.workspaceDir, adapter) opts := sdd.InjectOptions{ OpenCodeModelAssignments: s.selection.ModelAssignments, + VSCodeModelAssignments: s.selection.VSCodeModelAssignments, ClaudeModelAssignments: s.selection.ClaudeModelAssignments, ClaudePhaseAssignments: s.selection.ClaudePhaseAssignments, KiroModelAssignments: s.selection.KiroModelAssignments, @@ -652,6 +658,7 @@ func (s componentSyncStep) Run() error { return fmt.Errorf("sync sdd for %q: %w", adapter.Agent(), err) } s.countChanged(boolToInt(res.Changed), res.Files...) + s.addWarnings(res.Warnings...) } return nil @@ -748,6 +755,13 @@ func (s componentSyncStep) countChanged(n int, files ...string) { } } +func (s componentSyncStep) addWarnings(warnings ...string) { + if s.warnings == nil || len(warnings) == 0 { + return + } + *s.warnings = append(*s.warnings, warnings...) +} + // dedupPaths removes duplicate and empty paths while preserving first-seen order. func dedupPaths(paths []string) []string { if len(paths) == 0 { @@ -767,6 +781,36 @@ func dedupPaths(paths []string) []string { return out } +func dedupStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + if strings.TrimSpace(value) == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +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 +} + // boolToInt converts a boolean to 0 or 1. func boolToInt(b bool) int { if b { @@ -854,6 +898,7 @@ func RunSyncWithSelection(homeDir string, selection model.Selection) (SyncResult // (e.g. Engram and Context7 both merge into settings.json). result.ChangedFiles = dedupPaths(rt.changedFiles) result.FilesChanged = len(result.ChangedFiles) + result.Warnings = dedupStrings(rt.warnings) // True no-op: agents were discovered but all managed assets were already // current — no file was written or updated. Per spec scenario: @@ -944,6 +989,13 @@ func RunSync(args []string) (SyncResult, error) { } selection.ModelAssignments = m } + if len(selection.VSCodeModelAssignments) == 0 && len(persistedState.VSCodeModelAssignments) > 0 { + m := make(map[string]model.ModelAssignment, len(persistedState.VSCodeModelAssignments)) + for k, v := range persistedState.VSCodeModelAssignments { + m[k] = model.ModelAssignment{ProviderID: v.ProviderID, ModelID: v.ModelID, Effort: v.Effort} + } + selection.VSCodeModelAssignments = m + } // Restore Codex effort and carril model assignments from state so that // `gentle-ai sync` preserves the user's per-phase effort and per-carril // model choices instead of falling back to canonical defaults every time. @@ -1023,6 +1075,7 @@ func RenderSyncReport(result SyncResult) string { fmt.Fprintf(&b, "Agents: %s\n", joinAgentIDs(result.Agents)) fmt.Fprintln(&b, "All managed assets are already up to date. No files changed.") } + appendSyncWarnings(&b, result.Warnings) return strings.TrimRight(b.String(), "\n") } @@ -1064,6 +1117,8 @@ func RenderSyncReport(result SyncResult) string { } } + appendSyncWarnings(&b, result.Warnings) + if !result.Verify.Ready { fmt.Fprintln(&b, "") fmt.Fprintln(&b, "Post-sync verification:") @@ -1073,6 +1128,17 @@ func RenderSyncReport(result SyncResult) string { return strings.TrimRight(b.String(), "\n") } +func appendSyncWarnings(b *strings.Builder, warnings []string) { + if len(warnings) == 0 { + return + } + fmt.Fprintln(b, "") + fmt.Fprintln(b, "Warnings:") + for _, warning := range warnings { + fmt.Fprintf(b, " - %s\n", warning) + } +} + // runPostSyncVerification verifies that managed files exist after sync. func runPostSyncVerification(homeDir, workspaceDir string, selection model.Selection) verify.Report { checks := make([]verify.Check, 0) diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go index 0f9b2836f..5780dee19 100644 --- a/internal/cli/sync_test.go +++ b/internal/cli/sync_test.go @@ -2048,6 +2048,9 @@ func TestRunSyncLoadsPersistedModelAssignments(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) @@ -2080,6 +2083,39 @@ func TestRunSyncLoadsPersistedModelAssignments(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 := result.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) + } +} + +func TestRunSyncWithSelectionPropagatesVSCodeModelWarnings(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + selection := model.Selection{ + Agents: []model.AgentID{model.AgentVSCodeCopilot}, + Components: []model.ComponentID{model.ComponentSDD}, + VSCodeModelAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"}, + }, + } + + result, err := RunSyncWithSelection(home, selection) + if err != nil { + t.Fatalf("RunSyncWithSelection() error = %v", err) + } + if !containsSubstring(result.Warnings, "models cache") { + t.Fatalf("Warnings = %v, want missing cache warning", result.Warnings) + } + + content, err := os.ReadFile(filepath.Join(home, ".copilot", "agents", "sdd-apply.agent.md")) + if err != nil { + t.Fatalf("ReadFile(sdd-apply.agent.md): %v", err) + } + if strings.Contains(string(content), "model:") { + t.Fatalf("missing cache should omit model line; got:\n%s", content) + } } func TestRunSyncLoadsPersistedModelAssignmentsPreservesEffort(t *testing.T) { @@ -2944,3 +2980,12 @@ func TestRunSync_RestoresCodexPhaseModelAssignments(t *testing.T) { t.Fatalf("AGENTS.md rendered carril table instead of Custom per-phase table; got:\n%s", text) } } + +func containsSubstring(values []string, want string) bool { + for _, value := range values { + if strings.Contains(value, want) { + return true + } + } + return false +} diff --git a/internal/components/filemerge/replace_default.go b/internal/components/filemerge/replace_default.go new file mode 100644 index 000000000..83297f57c --- /dev/null +++ b/internal/components/filemerge/replace_default.go @@ -0,0 +1,9 @@ +//go:build !windows + +package filemerge + +import "os" + +func replaceFileAtomic(sourcePath, destinationPath string) error { + return os.Rename(sourcePath, destinationPath) +} diff --git a/internal/components/filemerge/replace_windows.go b/internal/components/filemerge/replace_windows.go new file mode 100644 index 000000000..7f50f1a33 --- /dev/null +++ b/internal/components/filemerge/replace_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package filemerge + +import "golang.org/x/sys/windows" + +const windowsAtomicReplaceFlags = windows.MOVEFILE_REPLACE_EXISTING | windows.MOVEFILE_WRITE_THROUGH + +func replaceFileAtomic(sourcePath, destinationPath string) error { + source, err := windows.UTF16PtrFromString(sourcePath) + if err != nil { + return err + } + destination, err := windows.UTF16PtrFromString(destinationPath) + if err != nil { + return err + } + return windows.MoveFileEx(source, destination, windowsAtomicReplaceFlags) +} diff --git a/internal/components/filemerge/writer.go b/internal/components/filemerge/writer.go index 9ed9f3bef..d3b3ef8a7 100644 --- a/internal/components/filemerge/writer.go +++ b/internal/components/filemerge/writer.go @@ -93,7 +93,7 @@ func WriteFileAtomic(path string, content []byte, perm fs.FileMode) (WriteResult return WriteResult{}, fmt.Errorf("close temp file for %q: %w", path, err) } - if err := os.Rename(tmpPath, path); err != nil { + if err := replaceFileAtomic(tmpPath, path); err != nil { return WriteResult{}, fmt.Errorf("replace %q atomically: %w", path, err) } diff --git a/internal/components/filemerge/writer_test.go b/internal/components/filemerge/writer_test.go index a21f8fbcf..02dfe95e5 100644 --- a/internal/components/filemerge/writer_test.go +++ b/internal/components/filemerge/writer_test.go @@ -85,6 +85,62 @@ func TestWriteFileAtomicCreatesAndIsIdempotent(t *testing.T) { } } +func TestWriteFileAtomicUpdatesExistingFileWithDifferentContent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sdd-apply.agent.md") + oldContent := []byte("old agent content\n") + newContent := []byte("new agent content\n") + + if err := os.WriteFile(path, oldContent, 0o644); err != nil { + t.Fatalf("WriteFile(old) error = %v", err) + } + + result, err := WriteFileAtomic(path, newContent, 0o644) + if err != nil { + t.Fatalf("WriteFileAtomic() error = %v, want successful replacement", err) + } + if !result.Changed || result.Created { + t.Fatalf("WriteFileAtomic() result = %+v, want Changed=true Created=false", result) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(got) != string(newContent) { + t.Fatalf("file content = %q, want %q", string(got), string(newContent)) + } +} + +func TestReplaceFileAtomicUpdatesExistingDestination(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, ".gentle-ai-source.tmp") + destinationPath := filepath.Join(dir, "sdd-explore.agent.md") + replacementContent := []byte("replacement agent content\n") + + if err := os.WriteFile(sourcePath, replacementContent, 0o644); err != nil { + t.Fatalf("WriteFile(source) error = %v", err) + } + if err := os.WriteFile(destinationPath, []byte("existing agent content\n"), 0o644); err != nil { + t.Fatalf("WriteFile(destination) error = %v", err) + } + + if err := replaceFileAtomic(sourcePath, destinationPath); err != nil { + t.Fatalf("replaceFileAtomic() error = %v, want successful replacement", err) + } + + got, err := os.ReadFile(destinationPath) + if err != nil { + t.Fatalf("ReadFile(destination) error = %v", err) + } + if string(got) != string(replacementContent) { + t.Fatalf("destination content = %q, want %q", string(got), string(replacementContent)) + } + if _, err := os.Stat(sourcePath); !os.IsNotExist(err) { + t.Fatalf("source stat error = %v, want source moved away", err) + } +} + func TestWriteFileAtomicRejectsExistingSymlink(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "target.txt") diff --git a/internal/components/sdd/inject.go b/internal/components/sdd/inject.go index 25b3ae589..bed3f4da0 100644 --- a/internal/components/sdd/inject.go +++ b/internal/components/sdd/inject.go @@ -18,12 +18,16 @@ import ( ) type InjectionResult struct { - Changed bool - Files []string + Changed bool + Files []string + Warnings []string } type InjectOptions struct { OpenCodeModelAssignments map[string]model.ModelAssignment + VSCodeModelAssignments map[string]model.ModelAssignment + VSCodeModelCachePath string + VSCodeModelVariantsPath string // ClaudeModelAssignments is the legacy model-only Claude assignment map. // Prefer ClaudePhaseAssignments for new callers that need per-phase effort. ClaudeModelAssignments map[string]model.ClaudeModelAlias @@ -225,8 +229,12 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt if len(options) > 0 { opts = options[0] } + if adapter.Agent() == model.AgentVSCodeCopilot { + opts = withDefaultVSCodeModelPaths(opts, homeDir) + } files := make([]string, 0) + warnings := make([]string, 0) changed := false // 1. Inject SDD orchestrator into the global system prompt for agents that @@ -677,6 +685,12 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt contentStr = strings.ReplaceAll(contentStr, "{{CLAUDE_MODEL}}", cmr.ClaudeModelID(assignment.Model)) contentStr = injectClaudeEffortFrontmatter(contentStr, assignment) } + + if adapter.Agent() == model.AgentVSCodeCopilot { + resolved, modelWarnings := renderVSCodeAgentModelAssignment(contentStr, entry.Name(), opts) + contentStr = resolved + warnings = append(warnings, modelWarnings...) + } outPath := filepath.Join(agentsDir, entry.Name()) writeResult, err := filemerge.WriteFileAtomic(outPath, []byte(contentStr), 0o644) if err != nil { @@ -688,10 +702,10 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt } } - // Post-check: verify critical agent files exist (either .md or .yaml) + // Post-check: verify critical agent files exist (.md, .agent.md, or .yaml). for _, phase := range []string{"sdd-apply", "sdd-verify"} { found := false - for _, ext := range []string{".md", ".yaml"} { + for _, ext := range []string{".md", ".agent.md", ".yaml"} { checkPath := filepath.Join(agentsDir, phase+ext) if info, err := os.Stat(checkPath); err == nil && info.Size() >= 10 { found = true @@ -702,6 +716,14 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt return InjectionResult{}, fmt.Errorf("post-check: sub-agent %q not written correctly (missing or truncated)", phase) } } + if adapter.Agent() == model.AgentVSCodeCopilot { + visibilityResult, err := hideManagedClaudeInternalAgentsForVSCode(homeDir) + if err != nil { + return InjectionResult{}, err + } + changed = changed || visibilityResult.Changed + files = append(files, visibilityResult.Files...) + } } // 4. Install skill-registry startup automation for agents with runtime hooks. @@ -796,7 +818,7 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt } } - return InjectionResult{Changed: changed, Files: files}, nil + return InjectionResult{Changed: changed, Files: files, Warnings: dedupWarnings(warnings)}, nil } func validateOpenClawWorkspacePath(workspaceDir string, adapter agents.Adapter) error { @@ -1672,7 +1694,7 @@ func injectFileAppend(homeDir string, adapter agents.Adapter, opts InjectOptions } // Use agent-specific SDD orchestrator content when available; fall back to generic. - content := assets.MustRead(sddOrchestratorAsset(adapter.Agent())) + content := sddOrchestratorContent(adapter.Agent()) // Codex-only: substitute {{CODEX_PHASE_EFFORTS}} with a rendered per-phase // effort table. Only fires when the adapter implements codexModelResolver. @@ -1709,6 +1731,18 @@ func injectFileAppend(homeDir string, adapter agents.Adapter, opts InjectOptions return InjectionResult{Changed: writeResult.Changed, Files: []string{promptPath}}, nil } +func sddOrchestratorContent(agent model.AgentID) string { + content := assets.MustRead(sddOrchestratorAsset(agent)) + if agent != model.AgentVSCodeCopilot { + return content + } + + return strings.TrimRight(content, "\n") + "\n\n" + vscodeCopilotSupportLayers + "\n" +} + +const vscodeCopilotSupportLayers = "### VS Code Copilot Support Layers\n\n" + + "Gentle AI installs three VS Code support layers: global instructions/rules at `Code/User/prompts/gentle-ai.instructions.md`, native custom agents in `~/.copilot/agents`, and SDD skills plus shared conventions in `~/.copilot/skills`." + func hasLegacyBareOrchestrator(content string) bool { markedIdx := strings.Index(content, "") if markedIdx >= 0 { diff --git a/internal/components/sdd/inject_test.go b/internal/components/sdd/inject_test.go index 6f43c0334..749e9eb29 100644 --- a/internal/components/sdd/inject_test.go +++ b/internal/components/sdd/inject_test.go @@ -1319,6 +1319,7 @@ func TestInjectQwenCodeWritesSDDOrchestratorAndSkills(t *testing.T) { func TestInjectVSCodeWritesSDDOrchestratorAndSkills(t *testing.T) { home := t.TempDir() + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) vscodeAdapter, err := agents.NewAdapter("vscode-copilot") @@ -1337,14 +1338,40 @@ func TestInjectVSCodeWritesSDDOrchestratorAndSkills(t *testing.T) { // Verify SDD orchestrator was injected into the VS Code instructions file. promptPath := vscodeAdapter.SystemPromptFile(home) + if !strings.HasSuffix(promptPath, filepath.Join("Code", "User", "prompts", "gentle-ai.instructions.md")) { + t.Fatalf("SystemPromptFile() = %q, want VS Code User prompts gentle-ai.instructions.md path", promptPath) + } + content, readErr := os.ReadFile(promptPath) if readErr != nil { t.Fatalf("ReadFile(%q) error = %v", promptPath, readErr) } text := string(content) - if !strings.Contains(text, "Spec-Driven Development") { - t.Fatal("VS Code system prompt missing SDD orchestrator content") + expectedInstructionMarkers := []string{ + "Code/User/prompts/gentle-ai.instructions.md", + "Spec-Driven Development", + "Artifact Store Policy", + "mem_search(query:", + "## Skills to load before work", + } + for _, marker := range expectedInstructionMarkers { + if !strings.Contains(text, marker) { + t.Fatalf("VS Code instructions file missing %q", marker) + } + } + + // Should also write native VS Code Copilot agent files under ~/.copilot/agents/. + agentFiles := []string{ + "sdd-orchestrator.agent.md", + "sdd-apply.agent.md", + "sdd-verify.agent.md", + } + for _, fileName := range agentFiles { + agentPath := filepath.Join(home, ".copilot", "agents", fileName) + if _, err := os.Stat(agentPath); err != nil { + t.Fatalf("expected VS Code Copilot agent file %q: %v", agentPath, err) + } } // Should also write SDD skill files under ~/.copilot/skills/. @@ -1353,9 +1380,22 @@ func TestInjectVSCodeWritesSDDOrchestratorAndSkills(t *testing.T) { t.Fatalf("expected SDD skill file %q: %v", skillPath, err) } - sharedPath := filepath.Join(home, ".copilot", "skills", "_shared", "engram-convention.md") - if _, err := os.Stat(sharedPath); err != nil { - t.Fatalf("expected shared SDD convention file %q: %v", sharedPath, err) + sharedFiles := []string{ + "persistence-contract.md", + "engram-convention.md", + "openspec-convention.md", + "sdd-phase-common.md", + "skill-resolver.md", + } + for _, fileName := range sharedFiles { + sharedPath := filepath.Join(home, ".copilot", "skills", "_shared", fileName) + info, err := os.Stat(sharedPath) + if err != nil { + t.Fatalf("expected shared SDD convention file %q: %v", sharedPath, err) + } + if info.Size() == 0 { + t.Fatalf("expected shared SDD convention file %q to be non-empty", sharedPath) + } } } @@ -4517,6 +4557,290 @@ func assertNativeAgentFile(t *testing.T, path string, contains string) { } } +func TestInjectVSCodeWritesNativeAgentFiles(t *testing.T) { + home := t.TempDir() + isolateDesktopConfig(t, home) + + adapter, err := agents.NewAdapter(model.AgentVSCodeCopilot) + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + + result, err := Inject(home, adapter, "") + if err != nil { + t.Fatalf("Inject(vscode) error = %v", err) + } + if !result.Changed { + t.Fatal("Inject(vscode) first changed = false") + } + + agentsDir := filepath.Join(home, ".copilot", "agents") + for _, name := range vscodeAgentAssetNames() { + path := filepath.Join(agentsDir, name) + content, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("expected VS Code agent file %q: %v", name, readErr) + } + if !strings.Contains(string(content), "target: vscode") { + t.Fatalf("%s missing target: vscode", name) + } + } + second, err := Inject(home, adapter, "") + if err != nil { + t.Fatalf("second Inject(vscode) error = %v", err) + } + if second.Changed { + t.Fatal("second Inject(vscode) changed = true; expected idempotent sync") + } + + entries, err := os.ReadDir(agentsDir) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v", agentsDir, err) + } + agentFiles := 0 + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".agent.md") { + agentFiles++ + } + } + if agentFiles != len(vscodeAgentAssetNames()) { + t.Fatalf("VS Code agent file count = %d, want %d", agentFiles, len(vscodeAgentAssetNames())) + } +} + +func TestInjectVSCodeRendersResolvedModelAssignment(t *testing.T) { + home := t.TempDir() + isolateDesktopConfig(t, home) + + adapter, err := agents.NewAdapter(model.AgentVSCodeCopilot) + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + cachePath := writeVSCodeModelCache(t, "gpt-4.1", "GPT-4.1", true) + opts := InjectOptions{ + VSCodeModelAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"}, + }, + VSCodeModelCachePath: cachePath, + } + + if _, err := Inject(home, adapter, "", opts); err != nil { + t.Fatalf("Inject(vscode) error = %v", err) + } + + applyPath := filepath.Join(home, ".copilot", "agents", "sdd-apply.agent.md") + applyContent, err := os.ReadFile(applyPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", applyPath, err) + } + if !strings.Contains(string(applyContent), `model: "GPT-4.1"`) { + t.Fatalf("sdd-apply.agent.md missing resolved model line; got:\n%s", applyContent) + } + + verifyPath := filepath.Join(home, ".copilot", "agents", "sdd-verify.agent.md") + verifyContent, err := os.ReadFile(verifyPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", verifyPath, err) + } + if strings.Contains(string(verifyContent), "model:") { + t.Fatalf("unassigned VS Code agent should inherit parent model; got:\n%s", verifyContent) + } + + second, err := Inject(home, adapter, "", opts) + if err != nil { + t.Fatalf("second Inject(vscode) error = %v", err) + } + if second.Changed { + t.Fatal("second Inject(vscode) changed = true; expected idempotent model rendering") + } +} + +func TestInjectVSCodeDefaultModelCacheUsesInjectedHome(t *testing.T) { + home := t.TempDir() + processHome := t.TempDir() + isolateDesktopConfig(t, home) + t.Setenv("HOME", processHome) + t.Setenv("USERPROFILE", processHome) + + adapter, err := agents.NewAdapter(model.AgentVSCodeCopilot) + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + cacheDir := filepath.Join(home, ".cache", "opencode") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s): %v", cacheDir, err) + } + cachePath := filepath.Join(cacheDir, "models.json") + cache := `{"github-copilot":{"id":"github-copilot","name":"GitHub Copilot","models":{"gpt-4.1":{"id":"gpt-4.1","name":"GPT-4.1","tool_call":true}}}}` + if err := os.WriteFile(cachePath, []byte(cache), 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", cachePath, err) + } + + opts := InjectOptions{VSCodeModelAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"}, + }} + if _, err := Inject(home, adapter, "", opts); err != nil { + t.Fatalf("Inject(vscode) error = %v", err) + } + + applyPath := filepath.Join(home, ".copilot", "agents", "sdd-apply.agent.md") + applyContent, err := os.ReadFile(applyPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", applyPath, err) + } + if !strings.Contains(string(applyContent), `model: "GPT-4.1"`) { + t.Fatalf("default cache path should use injected home, not process home; got:\n%s", applyContent) + } +} + +func TestInjectVSCodeMissingModelCacheWarnsWithoutModelLine(t *testing.T) { + home := t.TempDir() + isolateDesktopConfig(t, home) + + adapter, err := agents.NewAdapter(model.AgentVSCodeCopilot) + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + opts := InjectOptions{ + VSCodeModelAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"}, + }, + VSCodeModelCachePath: filepath.Join(t.TempDir(), "missing-models.json"), + } + + result, err := Inject(home, adapter, "", opts) + if err != nil { + t.Fatalf("Inject(vscode) error = %v", err) + } + if !containsWarning(result.Warnings, "models cache") { + t.Fatalf("Warnings = %v, want missing cache warning", result.Warnings) + } + + applyPath := filepath.Join(home, ".copilot", "agents", "sdd-apply.agent.md") + content, err := os.ReadFile(applyPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", applyPath, err) + } + if strings.Contains(string(content), "model:") { + t.Fatalf("unresolved VS Code assignment should omit model line; got:\n%s", content) + } +} + +func TestInjectVSCodeHidesExistingManagedClaudeInternalAgents(t *testing.T) { + home := t.TempDir() + isolateDesktopConfig(t, home) + + claudeAgentsDir := filepath.Join(home, ".claude", "agents") + if err := os.MkdirAll(claudeAgentsDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s): %v", claudeAgentsDir, err) + } + managedPhasePath := filepath.Join(claudeAgentsDir, "sdd-apply.md") + managedJudgePath := filepath.Join(claudeAgentsDir, "jd-judge-a.md") + unrelatedPath := filepath.Join(claudeAgentsDir, "sdd-custom.md") + managedPhase := "---\nname: sdd-apply\nmodel: sonnet\ntools: Read, Edit, Bash\n---\nBody\n" + managedJudge := "---\nname: jd-judge-a\nmodel: opus\ntools: Read, Grep\n---\nBody\n" + unrelated := "---\nname: sdd-custom\nmodel: sonnet\ntools: Read\n---\nUser-authored body\n" + for path, content := range map[string]string{ + managedPhasePath: managedPhase, + managedJudgePath: managedJudge, + unrelatedPath: unrelated, + } { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", path, err) + } + } + + adapter, err := agents.NewAdapter(model.AgentVSCodeCopilot) + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + result, err := Inject(home, adapter, "") + if err != nil { + t.Fatalf("Inject(vscode) error = %v", err) + } + + for _, path := range []string{managedPhasePath, managedJudgePath} { + content, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("ReadFile(%s): %v", path, readErr) + } + text := string(content) + if !strings.Contains(text, "\nuser-invocable: false\n") { + t.Fatalf("managed Claude internal agent %s should be hidden from VS Code picker:\n%s", filepath.Base(path), text) + } + if strings.Contains(text, "disable-model-invocation") { + t.Fatalf("managed Claude internal agent %s must remain subagent-invocable:\n%s", filepath.Base(path), text) + } + if !containsString(result.Files, path) { + t.Fatalf("result.Files missing patched Claude agent %s in %v", path, result.Files) + } + } + + unrelatedContent, err := os.ReadFile(unrelatedPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", unrelatedPath, err) + } + if string(unrelatedContent) != unrelated { + t.Fatalf("unrelated Claude-format user agent was modified:\n%s", unrelatedContent) + } + + second, err := Inject(home, adapter, "") + if err != nil { + t.Fatalf("second Inject(vscode) error = %v", err) + } + if second.Changed { + t.Fatalf("second Inject(vscode) changed = true after Claude visibility patch; files = %v", second.Files) + } +} + +func TestInjectNativeSubAgentExtensionsRemainAdapterSpecific(t *testing.T) { + tests := []struct { + name string + agentID model.AgentID + required string + forbidden string + customOpts InjectOptions + }{ + {name: "cursor", agentID: model.AgentCursor, required: filepath.Join(".cursor", "agents", "sdd-apply.md"), forbidden: filepath.Join(".cursor", "agents", "sdd-apply.agent.md")}, + {name: "kiro", agentID: model.AgentKiroIDE, required: filepath.Join(".kiro", "agents", "sdd-apply.md"), forbidden: filepath.Join(".kiro", "agents", "sdd-apply.agent.md")}, + {name: "claude", agentID: model.AgentClaudeCode, required: filepath.Join(".claude", "agents", "sdd-apply.md"), forbidden: filepath.Join(".claude", "agents", "sdd-apply.agent.md")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home := t.TempDir() + isolateDesktopConfig(t, home) + adapter, err := agents.NewAdapter(tt.agentID) + if err != nil { + t.Fatalf("NewAdapter(%s) error = %v", tt.agentID, err) + } + if _, err := Inject(home, adapter, "", tt.customOpts); err != nil { + t.Fatalf("Inject(%s) error = %v", tt.agentID, err) + } + if _, err := os.Stat(filepath.Join(home, tt.required)); err != nil { + t.Fatalf("required native sub-agent %q missing: %v", tt.required, err) + } + if _, err := os.Stat(filepath.Join(home, tt.forbidden)); !os.IsNotExist(err) { + t.Fatalf("adapter %s must not write VS Code .agent.md path %q; stat err = %v", tt.agentID, tt.forbidden, err) + } + }) + } +} + +func vscodeAgentAssetNames() []string { + names := []string{"sdd-orchestrator.agent.md"} + for _, phase := range []string{"sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard"} { + names = append(names, phase+".agent.md") + } + return names +} + +func isolateDesktopConfig(t *testing.T, home string) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) +} + // TestInjectKiroFallsBackToClaudeModelAssignmentsWhenKiroMapUnset verifies that // when KiroModelAssignments is nil, the injector falls back to ClaudeModelAssignments // for Kiro phase model resolution (legacy backward-compatible path). diff --git a/internal/components/sdd/vscode_agent_visibility.go b/internal/components/sdd/vscode_agent_visibility.go new file mode 100644 index 000000000..337f9d16b --- /dev/null +++ b/internal/components/sdd/vscode_agent_visibility.go @@ -0,0 +1,101 @@ +package sdd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gentleman-programming/gentle-ai/internal/components/filemerge" +) + +const hiddenClaudeInternalAgentFrontmatter = "user-invocable: false" + +func hideManagedClaudeInternalAgentsForVSCode(homeDir string) (InjectionResult, error) { + agentsDir := filepath.Join(homeDir, ".claude", "agents") + if info, err := os.Stat(agentsDir); err != nil { + if errors.Is(err, os.ErrNotExist) { + return InjectionResult{}, nil + } + return InjectionResult{}, fmt.Errorf("inspect Claude agents dir: %w", err) + } else if !info.IsDir() { + return InjectionResult{}, nil + } + + result := InjectionResult{} + for _, fileName := range managedClaudeInternalAgentFiles() { + path := filepath.Join(agentsDir, fileName) + content, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return InjectionResult{}, fmt.Errorf("read managed Claude agent %s: %w", fileName, err) + } + + text := string(content) + updated := hideClaudeAgentFromVSCodePicker(text) + if updated == text { + continue + } + writeResult, err := filemerge.WriteFileAtomic(path, []byte(updated), 0o644) + if err != nil { + return InjectionResult{}, fmt.Errorf("write managed Claude agent %s: %w", fileName, err) + } + if writeResult.Changed { + result.Changed = true + result.Files = append(result.Files, path) + } + } + return result, nil +} + +func managedClaudeInternalAgentFiles() []string { + return []string{ + "sdd-init.md", + "sdd-explore.md", + "sdd-propose.md", + "sdd-spec.md", + "sdd-design.md", + "sdd-tasks.md", + "sdd-apply.md", + "sdd-verify.md", + "sdd-archive.md", + "sdd-onboard.md", + "jd-judge-a.md", + "jd-judge-b.md", + "jd-fix-agent.md", + } +} + +func hideClaudeAgentFromVSCodePicker(content string) string { + lineBreak := "\n" + if strings.Contains(content, "\r\n") { + lineBreak = "\r\n" + } + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + closing := frontmatterClosingLine(lines) + if closing == -1 { + return content + } + + updated := make([]string, 0, len(lines)+1) + updated = append(updated, lines[0]) + hidden := false + for i := 1; i < closing; i++ { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "user-invocable:") { + if !hidden { + updated = append(updated, hiddenClaudeInternalAgentFrontmatter) + hidden = true + } + continue + } + updated = append(updated, lines[i]) + } + if !hidden { + updated = append(updated, hiddenClaudeInternalAgentFrontmatter) + } + updated = append(updated, lines[closing:]...) + return strings.Join(updated, lineBreak) +} diff --git a/internal/components/sdd/vscode_models.go b/internal/components/sdd/vscode_models.go new file mode 100644 index 000000000..920ded14f --- /dev/null +++ b/internal/components/sdd/vscode_models.go @@ -0,0 +1,250 @@ +package sdd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/opencode" +) + +const githubCopilotProviderID = "github-copilot" + +// vscodeModelAssignmentKeys defines the only VS Code agent keys that may receive +// explicit model frontmatter. Keeping this list closed prevents named-profile +// or arbitrary-file assignments from leaking into native Copilot agents. +func vscodeModelAssignmentKeys() []string { + return []string{ + "sdd-orchestrator", + "sdd-init", + "sdd-explore", + "sdd-propose", + "sdd-spec", + "sdd-design", + "sdd-tasks", + "sdd-apply", + "sdd-verify", + "sdd-archive", + "sdd-onboard", + } +} + +// withDefaultVSCodeModelPaths resolves cache paths relative to the injected +// home directory, not the process user. That keeps tests, portable installs, +// and sync-with-selection from accidentally reading another user's cache. +func withDefaultVSCodeModelPaths(opts InjectOptions, homeDir string) InjectOptions { + if opts.VSCodeModelCachePath == "" { + opts.VSCodeModelCachePath = filepath.Join(homeDir, ".cache", "opencode", "models.json") + } + if opts.VSCodeModelVariantsPath == "" { + opts.VSCodeModelVariantsPath = filepath.Join(homeDir, ".gentle-ai", "cache", "model-variants.json") + } + return opts +} + +// renderVSCodeAgentModelAssignment applies an optional model line to a single +// native VS Code agent file. Unresolved assignments are intentionally rendered +// without a model line so Copilot safely inherits the parent session model. +func renderVSCodeAgentModelAssignment(content, fileName string, opts InjectOptions) (string, []string) { + agentKey, ok := vscodeAgentKey(fileName) + if !ok { + return content, nil + } + if opts.VSCodeModelAssignments == nil { + return injectVSCodeModelLine(content, ""), nil + } + + modelLabel, warnings := resolveVSCodeModelAssignment( + agentKey, + opts.VSCodeModelAssignments, + opts.VSCodeModelCachePath, + opts.VSCodeModelVariantsPath, + ) + return injectVSCodeModelLine(content, modelLabel), warnings +} + +// vscodeAgentKey maps a managed `.agent.md` filename to its persisted assignment +// key. Non-native files are ignored so other adapter asset formats remain isolated. +func vscodeAgentKey(fileName string) (string, bool) { + if !strings.HasSuffix(fileName, ".agent.md") { + return "", false + } + key := strings.TrimSuffix(fileName, ".agent.md") + if key == "" { + return "", false + } + if !isVSCodeModelAssignmentKey(key) { + return "", false + } + return key, true +} + +// isVSCodeModelAssignmentKey enforces the closed assignment surface at runtime; +// tests alone are not enough because embedded assets can grow over time. +func isVSCodeModelAssignmentKey(key string) bool { + for _, allowed := range vscodeModelAssignmentKeys() { + if allowed == key { + return true + } + } + return false +} + +// resolveVSCodeModelAssignment validates an assignment against the dynamic +// OpenCode model cache and returns the VS Code display label to write. Any +// invalid or stale assignment returns a warning plus an empty label; the caller +// must then omit `model:` instead of failing sync. +func resolveVSCodeModelAssignment(agentKey string, assignments map[string]model.ModelAssignment, cachePath, variantsPath string) (string, []string) { + assignment, ok := assignments[agentKey] + if !ok || assignment.ProviderID == "" || assignment.ModelID == "" { + return "", nil + } + if assignment.ProviderID != githubCopilotProviderID { + return "", []string{fmt.Sprintf("VS Code model assignment for %s skipped: provider %q is not %q", agentKey, assignment.ProviderID, githubCopilotProviderID)} + } + + providers, warnings := loadVSCodeModelCatalog(agentKey, cachePath, variantsPath) + if len(warnings) > 0 { + return "", warnings + } + + provider, ok := providers[githubCopilotProviderID] + if !ok { + return "", []string{fmt.Sprintf("VS Code model assignment for %s skipped: provider %q missing from models cache", agentKey, githubCopilotProviderID)} + } + cachedModel, ok := provider.Models[assignment.ModelID] + if !ok { + return "", []string{fmt.Sprintf("VS Code model assignment for %s skipped: model %q missing from %q models cache", agentKey, assignment.ModelID, githubCopilotProviderID)} + } + if !cachedModel.ToolCall { + return "", []string{fmt.Sprintf("VS Code model assignment for %s skipped: model %q does not support tool calls", agentKey, assignment.ModelID)} + } + if warning := validateVSCodeEffort(agentKey, assignment, cachedModel); warning != "" { + return "", []string{warning} + } + + label := strings.TrimSpace(cachedModel.Name) + if label == "" { + label = strings.TrimSpace(cachedModel.ID) + } + if label == "" { + return "", []string{fmt.Sprintf("VS Code model assignment for %s skipped: model %q has insufficient metadata", agentKey, assignment.ModelID)} + } + return label, nil +} + +// loadVSCodeModelCatalog reads the current model cache and enriches it with +// optional effort variants. A missing cache is a recoverable warning because the +// assignment must stay persisted for a future cache refresh. +func loadVSCodeModelCatalog(agentKey, cachePath, variantsPath string) (map[string]opencode.Provider, []string) { + path := cachePath + if path == "" { + path = opencode.DefaultCachePath() + } + if path == "" { + return nil, []string{fmt.Sprintf("VS Code model assignment for %s skipped: models cache path unavailable", agentKey)} + } + + providers, err := opencode.LoadModels(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, []string{fmt.Sprintf("VS Code model assignment for %s skipped: models cache %q not found", agentKey, path)} + } + return nil, []string{fmt.Sprintf("VS Code model assignment for %s skipped: read models cache %q: %v", agentKey, path, err)} + } + + variantPath := variantsPath + if variantPath == "" { + variantPath = opencode.DefaultVariantsCachePath() + } + if variantPath != "" { + opencode.EnrichWithVariants(providers, variantPath) + } + return providers, nil +} + +// validateVSCodeEffort rejects effort-specific assignments unless the cache can +// prove that the chosen model supports that effort. This avoids writing a model +// frontmatter value that looks precise but cannot represent the requested variant. +func validateVSCodeEffort(agentKey string, assignment model.ModelAssignment, cachedModel opencode.Model) string { + if assignment.Effort == "" { + return "" + } + if len(cachedModel.Variants) == 0 { + return fmt.Sprintf("VS Code model assignment for %s skipped: model %q has no effort metadata", agentKey, assignment.ModelID) + } + for _, variant := range cachedModel.Variants { + if variant == assignment.Effort { + return "" + } + } + return fmt.Sprintf("VS Code model assignment for %s skipped: effort %q is not supported by model %q", agentKey, assignment.Effort, assignment.ModelID) +} + +// injectVSCodeModelLine rewrites only the YAML frontmatter model entry. It +// removes stale model lines first, preserves the asset line-ending style, and +// quotes labels so spaces or punctuation remain valid YAML. +func injectVSCodeModelLine(content, modelLabel string) string { + lineBreak := "\n" + if strings.Contains(content, "\r\n") { + lineBreak = "\r\n" + } + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + closing := frontmatterClosingLine(lines) + if closing == -1 { + return content + } + + updated := make([]string, 0, len(lines)+1) + updated = append(updated, lines[0]) + for i := 1; i < closing; i++ { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "model:") { + continue + } + updated = append(updated, lines[i]) + } + if modelLabel != "" { + updated = append(updated, "model: "+strconv.Quote(modelLabel)) + } + updated = append(updated, lines[closing:]...) + return strings.Join(updated, lineBreak) +} + +// frontmatterClosingLine finds the second YAML delimiter that ends frontmatter. +// Files without valid frontmatter are left untouched by the renderer. +func frontmatterClosingLine(lines []string) int { + if len(lines) < 2 || strings.TrimSpace(lines[0]) != "---" { + return -1 + } + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + return i + } + } + return -1 +} + +// dedupWarnings keeps warning output stable when multiple generated files hit +// the same recoverable cache or metadata problem during one sync. +func dedupWarnings(warnings []string) []string { + if len(warnings) == 0 { + return nil + } + seen := make(map[string]struct{}, len(warnings)) + out := make([]string, 0, len(warnings)) + for _, warning := range warnings { + if strings.TrimSpace(warning) == "" { + continue + } + if _, ok := seen[warning]; ok { + continue + } + seen[warning] = struct{}{} + out = append(out, warning) + } + return out +} diff --git a/internal/components/sdd/vscode_models_test.go b/internal/components/sdd/vscode_models_test.go new file mode 100644 index 000000000..f7af2142e --- /dev/null +++ b/internal/components/sdd/vscode_models_test.go @@ -0,0 +1,227 @@ +package sdd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gentleman-programming/gentle-ai/internal/model" +) + +// TestVSCodeModelAssignmentKeysIncludeCoordinatorAndPhases locks the native VS +// Code assignment surface to the coordinator plus real SDD phase agents. +func TestVSCodeModelAssignmentKeysIncludeCoordinatorAndPhases(t *testing.T) { + keys := vscodeModelAssignmentKeys() + + for _, want := range append([]string{"sdd-orchestrator"}, "sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard") { + if !containsString(keys, want) { + t.Fatalf("vscodeModelAssignmentKeys() missing %q in %v", want, keys) + } + } +} + +// TestVSCodeAgentKeyRejectsUnknownNativeFiles proves the assignment key list is +// an enforced allowlist, not just documentation for expected assets. +func TestVSCodeAgentKeyRejectsUnknownNativeFiles(t *testing.T) { + if _, ok := vscodeAgentKey("experimental.agent.md"); ok { + t.Fatal("unknown VS Code native agent file should not accept model assignments") + } +} + +// TestResolveVSCodeModelAssignment covers the resolver contract: valid dynamic +// Copilot cache entries render, while stale or unsafe assignments warn and omit. +func TestResolveVSCodeModelAssignment(t *testing.T) { + cachePath := writeVSCodeModelCache(t, "gpt-4.1", "GPT-4.1", true) + + tests := []struct { + name string + assignments map[string]model.ModelAssignment + cachePath string + wantLabel string + wantWarnings []string + }{ + { + name: "missing assignment inherits parent silently", + assignments: nil, + cachePath: cachePath, + wantLabel: "", + wantWarnings: nil, + }, + { + name: "valid github copilot cache entry renders display label", + assignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"}, + }, + cachePath: cachePath, + wantLabel: "GPT-4.1", + wantWarnings: nil, + }, + { + name: "provider mismatch warns and omits", + assignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "anthropic", ModelID: "claude-opus-4"}, + }, + cachePath: cachePath, + wantWarnings: []string{"github-copilot"}, + }, + { + name: "missing cache warns and omits", + assignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"}, + }, + cachePath: filepath.Join(t.TempDir(), "missing-models.json"), + wantWarnings: []string{"models cache"}, + }, + { + name: "non tool capable model warns and omits", + assignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "text-only"}, + }, + cachePath: writeVSCodeModelCache(t, "text-only", "Text Only", false), + wantWarnings: []string{"tool"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + label, warnings := resolveVSCodeModelAssignment("sdd-apply", tt.assignments, tt.cachePath, "") + if label != tt.wantLabel { + t.Fatalf("label = %q, want %q", label, tt.wantLabel) + } + for _, want := range tt.wantWarnings { + if !containsWarning(warnings, want) { + t.Fatalf("warnings = %v, want substring %q", warnings, want) + } + } + }) + } +} + +// TestResolveVSCodeModelAssignmentEffortMetadata proves effort assignments are +// only accepted when variant metadata can validate the requested effort. +func TestResolveVSCodeModelAssignmentEffortMetadata(t *testing.T) { + cachePath := writeVSCodeModelCache(t, "gpt-4.1", "GPT-4.1", true) + + _, warnings := resolveVSCodeModelAssignment("sdd-apply", map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1", Effort: "low"}, + }, cachePath, filepath.Join(t.TempDir(), "missing-variants.json")) + if !containsWarning(warnings, "effort metadata") { + t.Fatalf("warnings = %v, want missing effort metadata warning", warnings) + } + + variantsPath := writeVSCodeModelVariants(t, "gpt-4.1", []string{"low", "medium"}) + _, warnings = resolveVSCodeModelAssignment("sdd-apply", map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1", Effort: "high"}, + }, cachePath, variantsPath) + if !containsWarning(warnings, "effort") { + t.Fatalf("warnings = %v, want invalid effort warning", warnings) + } +} + +func TestRenderVSCodeAgentModelAssignmentStripsStaleModelPlaceholdersWithoutValidAssignment(t *testing.T) { + staleContent := strings.Join([]string{ + "---", + "name: sdd-orchestrator", + "target: vscode", + "user-invocable: true", + "model: {{VSC_MODEL}}", + "---", + "", + "Body", + }, "\n") + + tests := []struct { + name string + opts InjectOptions + wantWarning string + }{ + { + name: "missing assignments strips placeholder", + opts: InjectOptions{}, + }, + { + name: "invalid provider strips placeholder", + opts: InjectOptions{VSCodeModelAssignments: map[string]model.ModelAssignment{ + "sdd-orchestrator": {ProviderID: "anthropic", ModelID: "claude-opus-4"}, + }}, + wantWarning: "github-copilot", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, warnings := renderVSCodeAgentModelAssignment(staleContent, "sdd-orchestrator.agent.md", tt.opts) + if strings.Contains(got, "{{VSC_MODEL}}") { + t.Fatalf("rendered VS Code agent leaked raw placeholder:\n%s", got) + } + if strings.Contains(got, "model:") { + t.Fatalf("rendered VS Code agent should inherit parent model without model frontmatter:\n%s", got) + } + if tt.wantWarning != "" && !containsWarning(warnings, tt.wantWarning) { + t.Fatalf("warnings = %v, want substring %q", warnings, tt.wantWarning) + } + }) + } +} + +// writeVSCodeModelCache creates the smallest OpenCode-compatible model cache +// needed to test Copilot label resolution and tool-call filtering. +func writeVSCodeModelCache(t *testing.T, modelID, name string, toolCall bool) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "models.json") + content := `{"github-copilot":{"id":"github-copilot","name":"GitHub Copilot","models":{"` + modelID + `":{"id":"` + modelID + `","name":"` + name + `","tool_call":` + boolJSON(toolCall) + `}}}}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", path, err) + } + return path +} + +// writeVSCodeModelVariants writes provider variant metadata so effort validation +// can be tested without depending on the user's real cache. +func writeVSCodeModelVariants(t *testing.T, modelID string, efforts []string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "variants.json") + quoted := make([]string, 0, len(efforts)) + for _, effort := range efforts { + quoted = append(quoted, `"`+effort+`"`) + } + content := `{"github-copilot":{"` + modelID + `":[` + strings.Join(quoted, ",") + `]}}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", path, err) + } + return path +} + +// boolJSON keeps generated fixture JSON readable while avoiding fmt noise in +// table setup. +func boolJSON(value bool) string { + if value { + return "true" + } + return "false" +} + +// containsString keeps assignment-key assertions readable in closed-surface +// tests. +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +// containsWarning checks warning substrings so tests assert behavior without +// coupling to exact full diagnostic wording. +func containsWarning(warnings []string, want string) bool { + for _, warning := range warnings { + if strings.Contains(warning, want) { + return true + } + } + return false +} diff --git a/internal/components/uninstall/service_test.go b/internal/components/uninstall/service_test.go index 3c9ee97b5..84a2ebec9 100644 --- a/internal/components/uninstall/service_test.go +++ b/internal/components/uninstall/service_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/gentleman-programming/gentle-ai/internal/agents" + "github.com/gentleman-programming/gentle-ai/internal/assets" "github.com/gentleman-programming/gentle-ai/internal/backup" "github.com/gentleman-programming/gentle-ai/internal/model" ) @@ -292,6 +293,71 @@ func TestComponentOperationsSDD_ClaudeRemovesManagedCommandFiles(t *testing.T) { } } +func TestComponentOperationsSDD_VSCodeRemovesOnlyManagedAgentFiles(t *testing.T) { + homeDir := t.TempDir() + workspaceDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(homeDir, ".config")) + t.Setenv("APPDATA", filepath.Join(homeDir, "AppData", "Roaming")) + + svc, err := NewService(homeDir, workspaceDir, "dev") + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + adapter, ok := svc.registry.Get(model.AgentVSCodeCopilot) + if !ok { + t.Fatal("vscode adapter not found in registry") + } + + agentsDir := adapter.SubAgentsDir(homeDir) + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + t.Fatalf("MkdirAll(agents dir) error = %v", err) + } + + entries, err := assets.FS.ReadDir("vscode/agents") + if err != nil { + t.Fatalf("ReadDir(vscode/agents) error = %v", err) + } + managedFiles := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(agentsDir, entry.Name()) + managedFiles = append(managedFiles, path) + if err := os.WriteFile(path, []byte("managed"), 0o644); err != nil { + t.Fatalf("WriteFile(%s) error = %v", entry.Name(), err) + } + } + + customPath := filepath.Join(agentsDir, "custom.agent.md") + if err := os.WriteFile(customPath, []byte("user authored"), 0o644); err != nil { + t.Fatalf("WriteFile(custom.agent.md) error = %v", err) + } + + ops, _, err := svc.componentOperations(adapter, model.ComponentSDD) + if err != nil { + t.Fatalf("componentOperations() error = %v", err) + } + for _, op := range ops { + if op.path != agentsDir && !strings.HasPrefix(op.path, agentsDir+string(filepath.Separator)) { + continue + } + if _, _, err := op.apply(op.path); err != nil { + t.Fatalf("op.apply(%q) error = %v", op.path, err) + } + } + + for _, path := range managedFiles { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("managed VS Code agent %q should be removed; stat err = %v", path, err) + } + } + if _, err := os.Stat(customPath); err != nil { + t.Fatalf("custom VS Code agent should be preserved, stat err = %v", err) + } +} + func TestComponentOperationsSDD_OpenCodeRemovesManagedPluginSourcesAndModelVariantsCache(t *testing.T) { homeDir := t.TempDir() workspaceDir := t.TempDir() diff --git a/internal/model/selection.go b/internal/model/selection.go index 704a2f936..4099d91f1 100644 --- a/internal/model/selection.go +++ b/internal/model/selection.go @@ -11,6 +11,7 @@ type Selection struct { StrictTDD bool CodexMultiAgent bool // deprecated: Codex now always writes features.multi_agent = true; retained for state/back-compat ModelAssignments map[string]ModelAssignment // key = sub-agent name (e.g., "sdd-init") + VSCodeModelAssignments map[string]ModelAssignment // key = VS Code agent name (e.g., "sdd-apply") ClaudeModelAssignments map[string]ClaudeModelAlias // key = phase name; value = fable|opus|sonnet|haiku ClaudePhaseAssignments map[string]ClaudePhaseAssignment // key = phase name; value = Claude model+effort KiroModelAssignments map[string]KiroModelAlias // key = phase name; value = Kiro-native model alias @@ -53,6 +54,7 @@ type SyncOverrides struct { // model/profile configurators, where the user picked a concrete target agent. TargetAgents []AgentID ModelAssignments map[string]ModelAssignment // nil = no override; empty map = reset to defaults + VSCodeModelAssignments map[string]ModelAssignment // nil = no override; empty map = reset to defaults ClaudeModelAssignments map[string]ClaudeModelAlias // nil = no override; empty map = reset to defaults ClaudePhaseAssignments map[string]ClaudePhaseAssignment // nil = no override; empty map = reset to defaults KiroModelAssignments map[string]KiroModelAlias // nil = no override; empty map = reset to defaults diff --git a/internal/model/selection_test.go b/internal/model/selection_test.go index 9174d611c..aff9acf83 100644 --- a/internal/model/selection_test.go +++ b/internal/model/selection_test.go @@ -69,3 +69,24 @@ func TestSyncOverridesCodexModelPreset(t *testing.T) { t.Fatal("SyncOverrides.CodexModelAssignments should be non-nil after assignment") } } + +func TestSelectionAndSyncOverridesHaveVSCodeModelAssignments(t *testing.T) { + assignment := ModelAssignment{ProviderID: "github-copilot", ModelID: "gpt-4.1"} + + selection := Selection{ + ModelAssignments: map[string]ModelAssignment{"sdd-apply": {ProviderID: "anthropic", ModelID: "claude-opus-4"}}, + VSCodeModelAssignments: map[string]ModelAssignment{"sdd-apply": assignment}, + } + + if got := selection.VSCodeModelAssignments["sdd-apply"]; got != assignment { + t.Fatalf("Selection.VSCodeModelAssignments[sdd-apply] = %+v, want %+v", got, assignment) + } + if got := selection.ModelAssignments["sdd-apply"].ProviderID; got != "anthropic" { + t.Fatalf("Selection.ModelAssignments was affected by VS Code assignments: provider = %q", got) + } + + overrides := SyncOverrides{VSCodeModelAssignments: map[string]ModelAssignment{"sdd-verify": assignment}} + if got := overrides.VSCodeModelAssignments["sdd-verify"]; got != assignment { + t.Fatalf("SyncOverrides.VSCodeModelAssignments[sdd-verify] = %+v, want %+v", got, assignment) + } +} diff --git a/internal/state/state.go b/internal/state/state.go index 9f8714b79..ce9fc50a6 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -69,6 +69,9 @@ type InstallState struct { // ModelAssignments maps sub-agent names to provider/model pairs (OpenCode). ModelAssignments map[string]ModelAssignmentState `json:"model_assignments,omitempty"` + // VSCodeModelAssignments maps VS Code Copilot native agent names to provider/model pairs. + VSCodeModelAssignments map[string]ModelAssignmentState `json:"vscode_model_assignments,omitempty"` + // Persona records the persona the user installed ("gentleman", "neutral", // "custom"). Persisted so that `gentle-ai sync` regenerates the same persona // the user originally chose instead of defaulting to Gentleman every time. @@ -142,6 +145,7 @@ func MergeAgents(existing InstallState, newAgents []string) InstallState { return InstallState{ InstalledAgents: merged, ModelAssignments: existing.ModelAssignments, + VSCodeModelAssignments: existing.VSCodeModelAssignments, ClaudeModelAssignments: existing.ClaudeModelAssignments, ClaudePhaseAssignments: existing.ClaudePhaseAssignments, KiroModelAssignments: existing.KiroModelAssignments, diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 9b19a95c7..da8758c9d 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -14,6 +14,9 @@ func TestMergeAgents(t *testing.T) { existingAssignments := map[string]ModelAssignmentState{ "sdd-init": {ProviderID: "anthropic", ModelID: "claude-sonnet-4"}, } + existingVSCode := map[string]ModelAssignmentState{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"}, + } existingClaude := map[string]string{"sdd-explore": "sonnet", "sdd-archive": "haiku"} existingKiro := map[string]string{"sdd-design": "opus"} @@ -52,6 +55,7 @@ func TestMergeAgents(t *testing.T) { existing: InstallState{ InstalledAgents: []string{"opencode"}, ModelAssignments: existingAssignments, + VSCodeModelAssignments: existingVSCode, ClaudeModelAssignments: existingClaude, KiroModelAssignments: existingKiro, Persona: "gentleman", @@ -73,6 +77,9 @@ func TestMergeAgents(t *testing.T) { if !reflect.DeepEqual(got.ModelAssignments, tt.existing.ModelAssignments) { t.Errorf("ModelAssignments not preserved: got %v, want %v", got.ModelAssignments, tt.existing.ModelAssignments) } + if !reflect.DeepEqual(got.VSCodeModelAssignments, tt.existing.VSCodeModelAssignments) { + t.Errorf("VSCodeModelAssignments not preserved: got %v, want %v", got.VSCodeModelAssignments, tt.existing.VSCodeModelAssignments) + } if !reflect.DeepEqual(got.ClaudeModelAssignments, tt.existing.ClaudeModelAssignments) { t.Errorf("ClaudeModelAssignments not preserved: got %v, want %v", got.ClaudeModelAssignments, tt.existing.ClaudeModelAssignments) } @@ -267,6 +274,9 @@ func TestModelAssignmentsRoundTrip(t *testing.T) { ModelAssignments: map[string]ModelAssignmentState{ "sdd-init": {ProviderID: "anthropic", ModelID: "claude-sonnet-4"}, }, + VSCodeModelAssignments: map[string]ModelAssignmentState{ + "sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"}, + }, } if err := Write(home, want); err != nil { @@ -287,6 +297,9 @@ func TestModelAssignmentsRoundTrip(t *testing.T) { if !reflect.DeepEqual(got.ModelAssignments, want.ModelAssignments) { t.Errorf("ModelAssignments = %v, want %v", got.ModelAssignments, want.ModelAssignments) } + if !reflect.DeepEqual(got.VSCodeModelAssignments, want.VSCodeModelAssignments) { + t.Errorf("VSCodeModelAssignments = %v, want %v", got.VSCodeModelAssignments, want.VSCodeModelAssignments) + } } func TestClaudePhaseAssignmentsRoundTrip(t *testing.T) { @@ -393,6 +406,9 @@ func TestBackwardCompatNoAssignments(t *testing.T) { if s.ModelAssignments != nil { t.Errorf("ModelAssignments = %v, want nil", s.ModelAssignments) } + if s.VSCodeModelAssignments != nil { + t.Errorf("VSCodeModelAssignments = %v, want nil", s.VSCodeModelAssignments) + } } // TestInstallStateCodexRoundTrip verifies that CodexModelAssignments persists diff --git a/testdata/golden/sdd-claude-agent-sdd-apply.golden b/testdata/golden/sdd-claude-agent-sdd-apply.golden index fb4db97dc..98fa710c2 100644 --- a/testdata/golden/sdd-claude-agent-sdd-apply.golden +++ b/testdata/golden/sdd-claude-agent-sdd-apply.golden @@ -5,6 +5,7 @@ description: > should begin. Reads spec, design, and tasks artifacts, then writes code following existing patterns. Marks tasks complete as it goes. model: sonnet +user-invocable: false tools: Read, Edit, Write, Glob, Grep, Bash, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save, mcp__plugin_engram_engram__mem_update --- diff --git a/testdata/golden/sdd-claude-agent-sdd-archive.golden b/testdata/golden/sdd-claude-agent-sdd-archive.golden index 37c9faf9c..317aed866 100644 --- a/testdata/golden/sdd-claude-agent-sdd-archive.golden +++ b/testdata/golden/sdd-claude-agent-sdd-archive.golden @@ -5,6 +5,7 @@ description: > needs to be closed — merges delta specs into main specs, moves change folder to archive, and persists the final archive report. Completes the SDD cycle. model: haiku +user-invocable: false tools: Read, Edit, Write, Glob, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/testdata/golden/sdd-claude-agent-sdd-design.golden b/testdata/golden/sdd-claude-agent-sdd-design.golden index e2f8b6a93..367062ea2 100644 --- a/testdata/golden/sdd-claude-agent-sdd-design.golden +++ b/testdata/golden/sdd-claude-agent-sdd-design.golden @@ -5,6 +5,7 @@ description: > proposal is approved and the implementation approach needs to be chosen before tasks are broken down. model: opus +user-invocable: false tools: Read, Edit, Write, Grep, Glob, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/testdata/golden/sdd-claude-agent-sdd-explore.golden b/testdata/golden/sdd-claude-agent-sdd-explore.golden index eb0332d12..bb59f09c0 100644 --- a/testdata/golden/sdd-claude-agent-sdd-explore.golden +++ b/testdata/golden/sdd-claude-agent-sdd-explore.golden @@ -5,6 +5,7 @@ description: > a feature, investigate the codebase, understand current architecture, compare approaches, or clarify requirements — before any proposal or spec is written. model: sonnet +user-invocable: false tools: Read, Grep, Glob, WebFetch, WebSearch, mcp__plugin_engram_engram__mem_save --- diff --git a/testdata/golden/sdd-claude-agent-sdd-propose.golden b/testdata/golden/sdd-claude-agent-sdd-propose.golden index e10623f40..c9ffad7f0 100644 --- a/testdata/golden/sdd-claude-agent-sdd-propose.golden +++ b/testdata/golden/sdd-claude-agent-sdd-propose.golden @@ -4,6 +4,7 @@ description: > Create a change proposal with intent, scope, and approach. Use when exploration is complete and the idea is ready to be formalized into a proposal document. model: opus +user-invocable: false tools: Read, Edit, Write, Grep, Glob, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/testdata/golden/sdd-claude-agent-sdd-spec.golden b/testdata/golden/sdd-claude-agent-sdd-spec.golden index b3cd2bf2e..3a3503c52 100644 --- a/testdata/golden/sdd-claude-agent-sdd-spec.golden +++ b/testdata/golden/sdd-claude-agent-sdd-spec.golden @@ -4,6 +4,7 @@ description: > Write specifications with requirements and scenarios. Use when a proposal is approved and the change needs formal requirements (delta specs) captured before implementation. model: sonnet +user-invocable: false tools: Read, Edit, Write, Grep, Glob, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/testdata/golden/sdd-claude-agent-sdd-tasks.golden b/testdata/golden/sdd-claude-agent-sdd-tasks.golden index e7b2a23ec..5a13d8079 100644 --- a/testdata/golden/sdd-claude-agent-sdd-tasks.golden +++ b/testdata/golden/sdd-claude-agent-sdd-tasks.golden @@ -4,6 +4,7 @@ description: > Break down a change into an implementation task checklist. Use when spec and design are both ready and the change needs to be sliced into actionable, ordered work items. model: sonnet +user-invocable: false tools: Read, Edit, Write, Grep, Glob, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/testdata/golden/sdd-claude-agent-sdd-verify.golden b/testdata/golden/sdd-claude-agent-sdd-verify.golden index c3caeb420..30330f5f1 100644 --- a/testdata/golden/sdd-claude-agent-sdd-verify.golden +++ b/testdata/golden/sdd-claude-agent-sdd-verify.golden @@ -4,6 +4,7 @@ description: > Validate that implementation matches specs, design, and tasks. Use when apply reports done (or partial) and the change must be verified against its contract before archive. model: sonnet +user-invocable: false tools: Read, Grep, Glob, Bash, mcp__plugin_engram_engram__mem_search, mcp__plugin_engram_engram__mem_get_observation, mcp__plugin_engram_engram__mem_save --- diff --git a/testdata/golden/sdd-vscode-instructions.golden b/testdata/golden/sdd-vscode-instructions.golden index 3e208df9c..e9b593978 100644 --- a/testdata/golden/sdd-vscode-instructions.golden +++ b/testdata/golden/sdd-vscode-instructions.golden @@ -375,6 +375,10 @@ Convention files under the agent's global skills directory (global) or `.agent/s - `engram` → `mem_search(...)` → `mem_get_observation(...)` - `openspec` → read `openspec/changes/*/state.yaml` - `none` → state not persisted — explain to user + +### VS Code Copilot Support Layers + +Gentle AI installs three VS Code support layers: global instructions/rules at `Code/User/prompts/gentle-ai.instructions.md`, native custom agents in `~/.copilot/agents`, and SDD skills plus shared conventions in `~/.copilot/skills`.