From 354e0664052526c5060511879174ca274219b198 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 17:52:35 +0200 Subject: [PATCH 1/9] feat(agents): add VS Code Copilot SDD multi-mode support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable per-phase sub-agents for VS Code Copilot via .agent.md files, matching the multi-mode capability already shipped for OpenCode, Cursor and Kiro. - VS Code adapter now reports SupportsSubAgents() == true and exposes SubAgentsDir(homeDir) == ~/.copilot/agents/ plus EmbeddedSubAgentsDir() == "vscode/agents". - 10 embedded .agent.md templates under internal/assets/vscode/agents/ (one per SDD phase, sdd-init through sdd-onboard). Templates omit the model field on the default set so Copilot uses the user's default model. - New profile generator GenerateVSCodeProfileFiles(profile, agentsDir) ([]string, error) reads templates, resolves {{VSC_MODEL}} via a provider/model → Copilot display name table (vscModelEntries) and writes suffixed files atomically. - inject.go step 2c writes named-profile files for VS Code Copilot when SDD mode is multi and a non-default profile is configured; step-3c handles the default unsuffixed set unchanged. - RemoveVSCodeProfileAgents(agentsDir, profileName) removes the 10 suffixed phase files for a named profile; default profile rejected, non-gentle-ai files preserved. - Post-injection verification extended to recognize the .agent.md extension and validate sdd-apply.agent.md plus sdd-verify.agent.md are non-empty. TUI integration (welcome menu entry, profile create/edit screens, VS Code-specific model picker) lands in a separate PR. --- docs/vscode-sdd-profiles-research.md | 442 ++++++++++++++++++ internal/agents/vscode/adapter.go | 14 +- internal/agents/vscode/adapter_test.go | 50 ++ internal/agents/vscode/vscode_profiles.go | 227 +++++++++ .../agents/vscode/vscode_profiles_test.go | 286 ++++++++++++ internal/assets/assets.go | 2 +- internal/assets/assets_test.go | 40 ++ .../assets/vscode/agents/sdd-apply.agent.md | 51 ++ .../assets/vscode/agents/sdd-archive.agent.md | 19 + .../assets/vscode/agents/sdd-design.agent.md | 19 + .../assets/vscode/agents/sdd-explore.agent.md | 20 + .../assets/vscode/agents/sdd-init.agent.md | 20 + .../assets/vscode/agents/sdd-onboard.agent.md | 20 + .../assets/vscode/agents/sdd-propose.agent.md | 19 + .../assets/vscode/agents/sdd-spec.agent.md | 19 + .../assets/vscode/agents/sdd-tasks.agent.md | 19 + .../assets/vscode/agents/sdd-verify.agent.md | 20 + internal/components/sdd/inject.go | 62 ++- internal/components/sdd/vscode_inject_test.go | 164 +++++++ 19 files changed, 1506 insertions(+), 7 deletions(-) create mode 100644 docs/vscode-sdd-profiles-research.md create mode 100644 internal/agents/vscode/vscode_profiles.go create mode 100644 internal/agents/vscode/vscode_profiles_test.go create mode 100644 internal/assets/vscode/agents/sdd-apply.agent.md create mode 100644 internal/assets/vscode/agents/sdd-archive.agent.md create mode 100644 internal/assets/vscode/agents/sdd-design.agent.md create mode 100644 internal/assets/vscode/agents/sdd-explore.agent.md create mode 100644 internal/assets/vscode/agents/sdd-init.agent.md create mode 100644 internal/assets/vscode/agents/sdd-onboard.agent.md create mode 100644 internal/assets/vscode/agents/sdd-propose.agent.md create mode 100644 internal/assets/vscode/agents/sdd-spec.agent.md create mode 100644 internal/assets/vscode/agents/sdd-tasks.agent.md create mode 100644 internal/assets/vscode/agents/sdd-verify.agent.md create mode 100644 internal/components/sdd/vscode_inject_test.go diff --git a/docs/vscode-sdd-profiles-research.md b/docs/vscode-sdd-profiles-research.md new file mode 100644 index 000000000..53c01a056 --- /dev/null +++ b/docs/vscode-sdd-profiles-research.md @@ -0,0 +1,442 @@ +# VS Code SDD Profiles — Investigación y Análisis de Arquitectura + +> **Fecha**: 2026-05-11 +> **Contexto**: Análisis previo a la implementación de "VS Code SDD Profiles" para gentle-ai. +> **Estado**: Exploración completada — listo para fase de propuesta (`sdd-propose`). + +--- + +## 1. Resumen Ejecutivo + +El objetivo es replicar el comportamiento **Multi-mode SDD** de OpenCode (perfiles con modelos asignados a cada fase) pero para **VS Code Copilot**. La exploración inicial concluyó erróneamente que VS Code Copilot no soportaba modelos por fase. Sin embargo, la **documentación oficial de VS Code Copilot (actualizada a mayo 2026)** demuestra que VS Code tiene una infraestructura de custom agents y subagents con asignación de modelos **nativa**, comparable (y en algunos aspectos superior) a la de OpenCode. + +**Conclusión**: VS Code Copilot ya soporta multi-mode SDD de forma nativa mediante archivos `.agent.md`. No es necesario emular el comportamiento. + +--- + +## 2. Fuente de la Información + +Toda la información de este documento proviene de fuentes oficiales: + +- [Custom agents in VS Code](https://code.visualstudio.com/docs/copilot/customization/custom-agents) — docs oficiales, publicadas 2026-05-06 +- [Subagents in Visual Studio Code](https://code.visualstudio.com/docs/copilot/agents/subagents) — docs oficiales, publicadas 2026-05-06 +- [Visual Studio Code 1.119 Release Notes](https://code.visualstudio.com/updates/v1_119) — release oficial, 2026-05-06 +- [vscode-copilot-chat source](https://github.com/microsoft/vscode-copilot-chat) — Context7 indexado + +--- + +## 3. Evidencia Documental: VS Code Copilot Soporta Multi-Mode + +### 3.1. Custom Agents con Modelo Asignado + +VS Code Copilot permite definir custom agents en archivos `.agent.md` con YAML frontmatter. El campo `model` acepta: + +- Un modelo único: `model: "Claude Sonnet 4"` +- Un array de fallback: `model: ['Claude Opus 4.5', 'GPT-5.2']` + +**Ejemplo oficial**: + +```markdown +--- +name: test-writer +description: "Writes comprehensive unit tests for TypeScript code" +model: sonnet +allowedTools: + - Read + - Grep + - Glob + - Edit + - Write + - Bash +--- + +You are a test-writing specialist... +``` + +### 3.2. Ubicación Nativa para User-Level Agents + +Según la documentación oficial: + +| Scope | Default file location | +|---|---| +| Workspace | `.github/agents/` folder | +| Workspace (Claude format) | `.claude/agents/` folder | +| **User profile** | **`~/.copilot/agents/`** or your user data | + +> Fuente: [Custom agent file locations](https://code.visualstudio.com/docs/copilot/customization/custom-agents#_custom-agent-file-locations) + +**Implicación**: `~/.copilot/agents/` es el directorio nativo documentado para custom agents a nivel usuario. Gentle-ai ya usa `~/.copilot/skills/`, por lo que `~/.copilot/agents/` es consistente con la convención existente. + +### 3.3. Model Selection para Subagents + +La documentación establece una **prioridad de tres niveles** para la selección del modelo de un subagente: + +1. **Explicit model parameter**: el main agent especifica un modelo directamente al invocar `runSubagent` +2. **Agent-configured model**: la propiedad `model` en el frontmatter del `.agent.md` +3. **Main model**: el modelo que ejecuta la conversación padre + +**Ejemplo documentado**: + +``` +Run a subagent with Claude Sonnet 4.6 to research authentication patterns in this codebase. +``` + +> Fuente: [Model selection for subagents](https://code.visualstudio.com/docs/copilot/agents/subagents#_model-selection-for-subagents) + +### 3.4. Handoffs Nativos entre Agentes + +VS Code Copilot soporta **handoffs** en el frontmatter del agente — transiciones guiadas entre agentes con botón sugerido. Cada handoff puede especificar un modelo distinto: + +```yaml +--- +description: Generate an implementation plan +tools: ['search', 'web'] +handoffs: + - label: Start Implementation + agent: implementation + prompt: Now implement the plan outlined above. + send: false + model: GPT-5.2 (copilot) +--- +``` + +> Fuente: [Handoffs](https://code.visualstudio.com/docs/copilot/customization/custom-agents#_handoffs) + +**Nota**: OpenCode **NO** tiene handoffs nativos. Esta es una ventaja de VS Code Copilot. + +### 3.5. Restricción de Subagentes + +El main agent puede restringir qué subagentes puede invocar: + +```yaml +--- +name: TDD +tools: ['agent'] +agents: ['Red', 'Green', 'Refactor'] +--- +``` + +> Fuente: [Restrict which subagents can be used](https://code.visualstudio.com/docs/copilot/agents/subagents#_restrict-which-subagents-can-be-used-experimental) + +Esto es equivalente al `task` permission de OpenCode (`"sdd-apply": "allow"`). + +### 3.6. Agentes Ocultos (Solo Subagentes) + +```yaml +--- +name: internal-helper +user-invocable: false +--- +``` + +Equivalente a `"hidden": true` en `opencode.json`. + +### 3.7. Background Agents con Modelo Ligero + +Las release notes 1.119 (2026-05-06) confirman que VS Code ya usa múltiples modelos en paralelo: + +> "By offloading todo list management to a lightweight background agent, the main model can focus on the actual task while a smaller model keeps progress tracking in sync." + +Esto demuestra que la arquitectura multi-modelo de VS Code ya está en producción. + +--- + +## 4. Comparativa: OpenCode vs VS Code Copilot (Multi-Mode) + +| Feature | OpenCode | VS Code Copilot (oficial) | +|---|---|---| +| **Archivo de config de agentes** | `opencode.json` | `.agent.md` files (YAML frontmatter + Markdown body) | +| **Ubicación user-level** | `~/.config/opencode/` | `~/.copilot/agents/` | +| **Modelo por agente** | `"model": "provider/modelID"` | `model: "Claude Sonnet 4"` o `model: ['Claude Opus', 'GPT-5']` | +| **Subagent invocation con modelo** | ❌ No (hereda del orchestrator) | ✅ **SÍ** — explicit model parameter | +| **Handoffs entre agentes** | ❌ No | ✅ **SÍ** — nativo con `handoffs:` en frontmatter | +| **Tool restrictions** | ✅ Sí | ✅ Sí — `tools: ['read', 'search']` | +| **Agents restriction** | ✅ Sí (via `task` permissions) | ✅ **SÍ** — `agents: ['Planner', 'Implementer']` | +| **Agentes ocultos** | `"hidden": true` | `user-invocable: false` | +| **Fallback de modelos** | ❌ No | ✅ **SÍ** — array de prioridad | +| **Formato Claude compatible** | ❌ No | ✅ **SÍ** — detecta `.claude/agents/*.md` | + +--- + +## 5. Formato Nativo Propuesto para gentle-ai + +### 5.1. Sub-agente por fase (ejemplo: `sdd-apply`) + +Ubicación: `~/.copilot/agents/sdd-apply.agent.md` + +```markdown +--- +name: sdd-apply +description: "Implement code changes from task definitions" +model: "Claude Sonnet 4.6 (copilot)" +tools: ['read', 'write', 'edit', 'bash'] +user-invocable: false +disable-model-invocation: false +agents: [] +--- + +You are the sdd-apply agent. Implement code changes from task definitions... +``` + +### 5.2. Orchestrator (ejemplo: `gentle-orchestrator`) + +Ubicación: `~/.copilot/agents/gentle-orchestrator.agent.md` + +```markdown +--- +name: gentle-orchestrator +description: "SDD Orchestrator — coordinates sub-agents, never does work inline" +model: "Claude Opus 4.5 (copilot)" +tools: ['agent', 'read', 'write', 'edit', 'bash', 'delegate'] +agents: ['sdd-init', 'sdd-explore', 'sdd-propose', 'sdd-spec', + 'sdd-design', 'sdd-tasks', 'sdd-apply', 'sdd-verify', + 'sdd-archive', 'sdd-onboard'] +user-invocable: true +--- + +## Model Assignments + +Read this table at session start and cache it for the session. + +| Phase | Model | Reason | +|-------|-------|--------| +| orchestrator | Claude Opus 4.5 (copilot) | Coordinates, makes decisions | +| sdd-init | Claude Sonnet 4 (copilot) | Bootstrap SDD context | +| sdd-explore | Claude Sonnet 4 (copilot) | Reads code, structural | +| ... | ... | ... | + +## Sub-Agent References + +When delegating, always invoke the correct sub-agent by name: +- `sdd-init` for bootstrapping +- `sdd-explore` for investigation +- `sdd-apply` for implementation +... +``` + +### 5.3. Handoffs (opcional, para flujos guiados) + +El orchestrator puede definir handoffs para guiar al usuario entre fases: + +```yaml +handoffs: + - label: "Start Exploration" + agent: sdd-explore + prompt: "Explore this codebase to understand..." + send: false +``` + +--- + +## 6. Implicaciones para el Diseño de gentle-ai + +### 6.1. Cambios en el Adaptador VS Code + +El adaptador actual (`internal/agents/vscode/adapter.go`) tiene: + +```go +func (a *Adapter) SupportsSubAgents() bool { + return false // ❌ DEBE CAMBIAR A true +} + +func (a *Adapter) SubAgentsDir(_ string) string { + return "" // ❌ DEBE RETORNAR ~/.copilot/agents/ +} +``` + +**Cambios necesarios**: +- `SupportsSubAgents()`: retornar `true` +- `SubAgentsDir(homeDir)`: retornar `filepath.Join(homeDir, ".copilot", "agents")` +- `EmbeddedSubAgentsDir()`: definir path en assets embebidos (ej: `vscode/agents/`) +- Posiblemente agregar `SupportsWorkflows()` o similar si se usan handoffs + +### 6.2. Nuevo Componente: Generador de `.agent.md` + +Se necesita un componente equivalente a `GenerateProfileOverlay` de OpenCode, pero que genere archivos `.agent.md` en lugar de JSON. + +**Responsabilidades**: +- Generar 11 archivos `.agent.md` por perfil (1 orchestrator + 10 fases) +- Inyectar tabla de model assignments en el body del orchestrator +- Asignar `user-invocable: false` a los sub-agentes +- Asignar `model` a cada agente según el perfil +- Manejar handoffs opcionales + +### 6.3. Estrategia de Inyección + +A diferencia de OpenCode (que hace deep-merge en `opencode.json`), VS Code Copilot requiere: + +- Escribir archivos `.agent.md` físicos en `~/.copilot/agents/` +- No hay merge complejo — cada archivo es independiente +- Borrar archivos de perfiles eliminados (cleanup) +- Manejar nombres de archivo con sufijos para perfiles nombrados (ej: `sdd-apply-cheap.agent.md`) + +### 6.4. Desacoplamiento (Golden Rule) + +Siguiendo la Golden Rule del CODEBASE-GUIDE: + +> "agent-specific paths belong in adapters; reusable behavior belongs in components" + +- **Adaptador** (`internal/agents/vscode/`): define `SubAgentsDir()`, `EmbeddedSubAgentsDir()`, capabilities +- **Componente** (`internal/components/sdd/`): generador de `.agent.md` reusable (similar a `profiles.go` para OpenCode) +- **Assets** (`internal/assets/vscode/`): templates de `.agent.md` embebidos + +--- + +## 7. Las 5 Fases de Implementación + +### Fase 1: Comprensión del contexto y la arquitectura ✅ + +**Estado**: COMPLETADA. + +Se investigó: +- Adaptador actual de VS Code (`internal/agents/vscode/adapter.go`) +- Adaptador de OpenCode y su mecanismo de perfiles +- Documentación oficial de VS Code Copilot (custom agents, subagents, handoffs) +- Infraestructura de assets embebidos y componentes SDD + +**Hallazgo clave**: VS Code Copilot soporta multi-mode nativamente via `.agent.md` files. + +### Fase 2: Modificación del adaptador de VS Code + +**Objetivo**: Habilitar `SupportsSubAgents`, definir paths, agregar tests. + +**Archivos a tocar**: +- `internal/agents/vscode/adapter.go` +- `internal/agents/vscode/adapter_test.go` + +### Fase 3: Generación de los archivos `.agent.md` por fase + +**Objetivo**: Crear generador de `.agent.md` y templates embebidos. + +**Archivos a tocar**: +- `internal/components/sdd/vscode_profiles.go` (nuevo — generador) +- `internal/assets/vscode/` (nuevo — templates embebidos) +- `internal/components/sdd/inject.go` (modificar — agregar path VS Code) +- Tests correspondientes + +### Fase 4: Orquestación mediante Handoffs + +**Objetivo**: Definir handoffs en el orchestrator para guiar flujos SDD. + +**Archivos a tocar**: +- Template del orchestrator `.agent.md` +- Configuración de handoffs en el generador de perfiles + +### Fase 5: Revisión y manejo de errores (Fallback) + +**Objetivo**: Tests de integración, validación post-inyección, rollback. + +**Archivos a tocar**: +- `internal/agents/vscode/adapter_test.go` +- `internal/components/sdd/vscode_profiles_test.go` (nuevo) +- `internal/components/sdd/inject_test.go` (modificar) + +--- + +## 8. Decisiones de Arquitectura + +### 8.1. ¿Handoffs o no handoffs? + +**Recomendación**: Implementar handoffs en una v2. Para la v1, mantener el mismo patrón que OpenCode: el orchestrator delega explícitamente a sub-agentes. Los handoffs agregan complejidad y no están en OpenCode. + +### 8.2. ¿Un solo perfil o múltiples perfiles? + +**Recomendación**: Replicar la misma semántica que OpenCode: +- Perfil default (sin sufijo): `gentle-orchestrator.agent.md`, `sdd-apply.agent.md`, etc. +- Perfiles nombrados (con sufijo): `sdd-orchestrator-cheap.agent.md`, `sdd-apply-cheap.agent.md`, etc. +- El TUI de gentle-ai ya tiene flujo de creación de perfiles — reutilizarlo. + +### 8.3. ¿Dónde vive el system prompt del orchestrator? + +**Opción A**: Todo en `gentle-orchestrator.agent.md` (incluyendo model assignments table). +**Opción B**: System prompt en `gentle-ai.instructions.md` + `.agent.md` files en `~/.copilot/agents/`. + +**Recomendación**: Opción A. El archivo `.agent.md` del orchestrator IS the system prompt. No duplicar en `gentle-ai.instructions.md`. Sin embargo, el `gentle-ai.instructions.md` puede seguir existiendo para instrucciones generales de gentle-ai que no son SDD-specific. + +### 8.4. ¿Cómo se detecta que VS Code lee los agentes? + +**Validación**: Después de la inyección, gentle-ai debería verificar que los archivos `.agent.md` existen y tienen contenido válido (similar al post-check de OpenCode que valida `gentle-orchestrator` en `opencode.json`). + +--- + +## 8.1. Principio de Desacoplamiento Obligatorio (Golden Rule) + +> **"Las rutas y configuraciones específicas de un agente pertenecen a los adaptadores."** +> — CODEBASE-GUIDE.md, Golden Rule + +Esta feature debe implementarse con **cero impacto** en cualquier otro agente. Los principios son: + +### 8.1.1. Aditivo, no Modificativo +- Se AGREGA el adaptador VS Code (`SupportsSubAgents: true`, `SubAgentsDir()`) +- Se CREA un nuevo componente (`vscode_profiles.go`) — no se modifica `profiles.go` de OpenCode +- Se CREA un nuevo directorio de assets (`internal/assets/vscode/`) — no se toca `internal/assets/opencode/` +- Se AGREGA un nuevo path en `inject.go` para el caso `AgentVSCodeCopilot` — el flujo de OpenCode permanece intacto + +### 8.1.2. No Tocar Interfaces Existentes +- `agents.Adapter` interface: NO agregar métodos nuevos que obliguen a otros adaptadores a implementar stubs +- `model.Profile`: reutilizar el tipo existente (ya es agnóstico del agente) +- `model.ModelAssignment`: reutilizar el tipo existente + +### 8.1.3. Namespace Aislado +- Todos los archivos generados usan prefijo `gentle-` o `sdd-` (ej: `sdd-apply.agent.md`) +- No sobrescribir agentes existentes del usuario en `~/.copilot/agents/` +- Cleanup al desinstalar: solo borrar archivos que gentle-ai creó (identificables por prefijo) + +### 8.1.4. Tests Aislados +- Tests del adaptador VS Code: solo prueban el adaptador VS Code +- Tests del generador `.agent.md`: solo prueban el generador +- Tests de integración: verificar que OpenCode, Claude, Cursor, etc. NO se ven afectados + +### 8.1.5. Feature Flag Implícito +- Si el usuario NO selecciona VS Code Copilot como agente, el código nuevo nunca se ejecuta +- Si el usuario tiene VS Code instalado pero NO configura perfiles SDD, el comportamiento default es single-mode (igual que hoy) + +--- + +## 9. Riesgos Identificados + +| Riesgo | Probabilidad | Impacto | Mitigación | +|---|---|---|---| +| VS Code Insiders vs Stable: custom agents avanzados pueden requerir Insiders | Media | Alta | Documentar requisito de versión mínima | +| `.agent.md` en `~/.copilot/agents/` no es detectado por VS Code Stable | Baja | Alta | Validar con VS Code 1.119+ antes de release | +| El campo `model` en frontmatter no acepta el mismo formato que OpenCode | Media | Media | Mapear formatos en el generador (provider/model → "Model Name (vendor)") | +| Conflicto con agentes existentes del usuario en `~/.copilot/agents/` | Baja | Baja | Namespace con prefijo `gentle-` o `sdd-` | +| Tamaño del prompt del orchestrator con tabla de model assignments | Baja | Media | La tabla es texto plano, no debería exceder límites | + +--- + +## 10. Archivos Preliminares a Modificar + +### Adaptador VS Code +- `internal/agents/vscode/adapter.go` — habilitar sub-agents, definir paths +- `internal/agents/vscode/adapter_test.go` — tests de capabilities y paths + +### Componente SDD (nuevo o modificado) +- `internal/components/sdd/vscode_profiles.go` — generador de `.agent.md` +- `internal/components/sdd/vscode_profiles_test.go` — tests del generador +- `internal/components/sdd/inject.go` — agregar path de inyección VS Code +- `internal/components/sdd/inject_test.go` — tests de inyección + +### Assets embebidos (nuevo) +- `internal/assets/vscode/sdd-orchestrator.md` — template del orchestrator +- `internal/assets/vscode/sdd-init.md` — template de fase +- `internal/assets/vscode/sdd-explore.md` — template de fase +- ... (10 fases) + +### Modelo +- `internal/model/types.go` — posiblemente agregar `AgentVSCodeCopilot` (ya existe) +- `internal/model/model_assignment.go` — posiblemente agregar conversión de formato de modelo + +### TUI +- `internal/tui/screens/model_config.go` — posiblemente ajustar picker para VS Code +- `internal/tui/screens/profiles.go` — reutilizar flujo existente + +--- + +## 11. Próximo Paso + +Lanzar `sdd-propose` con este contexto para formalizar la propuesta técnica, seguido de `sdd-spec`, `sdd-design`, `sdd-tasks`, `sdd-apply`, `sdd-verify`, y `sdd-archive`. + +--- + +*Documento generado por gentle-ai SDD exploration phase.* diff --git a/internal/agents/vscode/adapter.go b/internal/agents/vscode/adapter.go index abf0f4fdd..f44fcb700 100644 --- a/internal/agents/vscode/adapter.go +++ b/internal/agents/vscode/adapter.go @@ -133,15 +133,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 { @@ -164,3 +164,9 @@ type AgentNotInstallableError struct { func (e AgentNotInstallableError) Error() string { return "agent " + string(e.Agent) + " is a desktop app and cannot be installed via CLI" } + +// VSCModelID resolves a ModelAssignment to a VS Code Copilot display name. +// Used by the SDD injector to stamp the model field in .agent.md frontmatter. +func (a *Adapter) VSCModelID(m model.ModelAssignment) string { + return VSCodeModelID(m) +} diff --git a/internal/agents/vscode/adapter_test.go b/internal/agents/vscode/adapter_test.go index e05091ab3..2b60aabc0 100644 --- a/internal/agents/vscode/adapter_test.go +++ b/internal/agents/vscode/adapter_test.go @@ -63,6 +63,56 @@ func TestSettingsPathUsesVSCodeUserProfile(t *testing.T) { } } +func TestSupportsSubAgents_ReturnsTrue(t *testing.T) { + a := NewAdapter() + if !a.SupportsSubAgents() { + t.Fatal("SupportsSubAgents() = false, want true") + } +} + +func TestSubAgentsDir_CrossPlatform(t *testing.T) { + a := NewAdapter() + + tests := []struct { + name string + homeDir string + want string + }{ + { + name: "macOS", + homeDir: "/Users/alice", + want: filepath.Join("/Users/alice", ".copilot", "agents"), + }, + { + name: "Linux with default home", + homeDir: "/home/bob", + want: filepath.Join("/home/bob", ".copilot", "agents"), + }, + { + name: "Windows with home dir", + homeDir: `C:\Users\charlie`, + want: filepath.Join(`C:\Users\charlie`, ".copilot", "agents"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := a.SubAgentsDir(tt.homeDir) + if got != tt.want { + t.Fatalf("SubAgentsDir(%q) = %q, want %q", tt.homeDir, got, tt.want) + } + }) + } +} + +func TestEmbeddedSubAgentsDir(t *testing.T) { + a := NewAdapter() + got := a.EmbeddedSubAgentsDir() + if got != "vscode/agents" { + t.Fatalf("EmbeddedSubAgentsDir() = %q, want %q", got, "vscode/agents") + } +} + func TestMCPConfigPathUsesVSCodeUserProfile(t *testing.T) { a := NewAdapter() home := "/tmp/home" diff --git a/internal/agents/vscode/vscode_profiles.go b/internal/agents/vscode/vscode_profiles.go new file mode 100644 index 000000000..fcb894f28 --- /dev/null +++ b/internal/agents/vscode/vscode_profiles.go @@ -0,0 +1,227 @@ +package vscode + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/gentleman-programming/gentle-ai/internal/assets" + "github.com/gentleman-programming/gentle-ai/internal/components/filemerge" + "github.com/gentleman-programming/gentle-ai/internal/model" +) + +// vscModelEntries maps model ID substrings to VS Code Copilot display names. +// Unknown models fall back to "ProviderID/ModelID" for traceability. +// Empty model ID means no model field — Copilot uses its default. +// Entries are checked in order; longer/more-specific substrings must come first +// to avoid partial matches. Critical pairs today: +// - "gpt-4o-mini" before "gpt-4o" +// - "gpt-4.1-mini" before "gpt-4.1" +// +// Future risk: when a new Claude Sonnet (e.g. "claude-sonnet-4-5") or any other +// versioned successor lands, place the more specific entry BEFORE the broader +// "claude-sonnet-4" entry — otherwise the broader substring wins. +var vscModelEntries = []struct { + substr string + display string +}{ + {"claude-sonnet-4", "Claude Sonnet 4 (copilot)"}, + {"claude-opus-4-5", "Claude Opus 4.5 (copilot)"}, + {"claude-haiku-4-5", "Claude Haiku 4.5 (copilot)"}, + {"gemini-2.5-pro", "Gemini 2.5 Pro (copilot)"}, + {"gemini-2.5-flash", "Gemini 2.5 Flash (copilot)"}, + {"gpt-4.1-mini", "GPT 4.1 Mini (copilot)"}, + {"gpt-4o-mini", "GPT 4o Mini (copilot)"}, + {"gpt-4.1", "GPT 4.1 (copilot)"}, + {"gpt-4o", "GPT 4o (copilot)"}, +} + +// VSCodeModelID maps a ModelAssignment (provider/model) to a VS Code Copilot +// display name. Known models get friendly display names; unknown models get +// the full ProviderID/ModelID as a fallback. Empty ModelID returns empty string +// (meaning: omit the model field entirely — Copilot uses its default). +func VSCodeModelID(m model.ModelAssignment) string { + if m.ModelID == "" { + return "" + } + for _, entry := range vscModelEntries { + if strings.Contains(m.ModelID, entry.substr) { + return entry.display + } + } + // Fallback: use full qualified ID for traceability + return m.ProviderID + "/" + m.ModelID +} + +// SDD phases in canonical order (excludes orchestrator, which is handled specially). +var sddPhases = []string{ + "sdd-init", + "sdd-explore", + "sdd-propose", + "sdd-spec", + "sdd-design", + "sdd-tasks", + "sdd-apply", + "sdd-verify", + "sdd-archive", + "sdd-onboard", +} + +// sddPhaseDescriptions provides short descriptions for each SDD phase agent. +var sddPhaseDescriptions = map[string]string{ + "sdd-init": "Initialize SDD context for the project", + "sdd-explore": "Investigate ideas and approaches before committing to a change", + "sdd-propose": "Draft a change proposal with intent and scope", + "sdd-spec": "Write requirements and acceptance scenarios", + "sdd-design": "Write architecture and file-change design", + "sdd-tasks": "Break down a change into implementation task checklist", + "sdd-apply": "Implement code changes from task definitions", + "sdd-verify": "Validate implementation against specs and design", + "sdd-archive": "Sync delta specs and archive completed change", + "sdd-onboard": "Guided end-to-end SDD walkthrough", +} + +// GenerateAgentFile produces .agent.md content with YAML frontmatter and markdown body +// for a VS Code Copilot sub-agent. The profile name is used to suffix the agent name +// for named profiles (e.g., "sdd-apply-cheap"), and omitted for the default profile. +func GenerateAgentFile(phase string, profile model.Profile) string { + agentName := phase + if profile.Name != "" && profile.Name != "default" { + agentName = phase + "-" + profile.Name + } + + description := sddPhaseDescriptions[phase] + if description == "" { + description = "SDD " + phase + " executor" + } + + // Build YAML frontmatter + var sb strings.Builder + sb.WriteString("---\n") + sb.WriteString(fmt.Sprintf("name: %s\n", agentName)) + sb.WriteString(fmt.Sprintf("description: >\n %s\n", description)) + + // Model resolution: if the phase has a model assignment, resolve it + if assignment, ok := profile.PhaseAssignments[phase]; ok { + modelID := VSCodeModelID(assignment) + if modelID != "" { + sb.WriteString(fmt.Sprintf("model: \"%s\"\n", modelID)) + } + } + // If no assignment, the model field is omitted — Copilot uses its default + + sb.WriteString("readonly: false\n") + sb.WriteString("background: false\n") + // Phase executors are NOT user-invocable — they are dispatched by the orchestrator + sb.WriteString("user-invocable: false\n") + sb.WriteString("---\n\n") + + // Markdown body — SDD phase executor instructions + sb.WriteString(fmt.Sprintf("You are the SDD **%s** executor. Do this phase's work yourself. Do NOT delegate further.\n", phase)) + sb.WriteString("You are not the orchestrator. Do NOT call task/delegate. Do NOT launch sub-agents.\n\n") + sb.WriteString("## Instructions\n\n") + sb.WriteString(fmt.Sprintf("Read the skill file at `~/.copilot/skills/sdd-%s/SKILL.md` and follow it exactly.\n", phaseWithoutPrefix(phase))) + sb.WriteString("Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`.\n") + + return sb.String() +} + +// phaseWithoutPrefix strips the "sdd-" prefix from a phase name for skill directory lookup. +func phaseWithoutPrefix(phase string) string { + return strings.TrimPrefix(phase, "sdd-") +} + +// SDDPhases returns the canonical list of SDD phases (10 phases, no orchestrator). +func SDDPhases() []string { + result := make([]string, len(sddPhases)) + copy(result, sddPhases) + return result +} + +// GenerateVSCodeProfileFiles writes 10 .agent.md files (one per SDD phase) +// for a named VS Code profile to the agents directory. Returns a list of written +// file paths. Default profile (name="" or name="default") is handled by the +// existing 3c block in inject.go and should NOT go through this function. +func GenerateVSCodeProfileFiles(profile model.Profile, agentsDir string) ([]string, error) { + if profile.Name == "" || profile.Name == "default" { + return nil, fmt.Errorf("GenerateVSCodeProfileFiles: default profile is handled by the generic sub-agent path, not profile generation") + } + + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + return nil, fmt.Errorf("create agents dir: %w", err) + } + + var files []string + + for _, phase := range sddPhases { + content := GenerateAgentFile(phase, profile) + fileName := phase + "-" + profile.Name + ".agent.md" + outPath := filepath.Join(agentsDir, fileName) + + writeResult, err := filemerge.WriteFileAtomic(outPath, []byte(content), 0o644) + if err != nil { + return nil, fmt.Errorf("write profile agent %q: %w", fileName, err) + } + if writeResult.Changed { + files = append(files, outPath) + } + } + + return files, nil +} + +// RemoveVSCodeProfileAgents removes all sdd-*-{profileName}.agent.md files from +// the agents directory. Default profile (name="" or "default") MUST NOT be removed +// and returns an error. Missing files are silently skipped (no error). +// Non-gentle-ai files in the agents directory are NOT touched. +func RemoveVSCodeProfileAgents(agentsDir, profileName string) error { + if profileName == "" || profileName == "default" { + return fmt.Errorf("cannot remove default profile") + } + + entries, err := os.ReadDir(agentsDir) + if err != nil { + if os.IsNotExist(err) { + return nil // nothing to remove + } + return fmt.Errorf("read agents dir: %w", err) + } + + suffix := "-" + profileName + ".agent.md" + for _, entry := range entries { + if entry.IsDir() { + continue + } + // Only remove files matching sdd-*-{profileName}.agent.md pattern + if strings.HasPrefix(entry.Name(), "sdd-") && strings.HasSuffix(entry.Name(), suffix) { + path := filepath.Join(agentsDir, entry.Name()) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %q: %w", path, err) + } + } + } + + return nil +} + +// ReadVSCodeAgentTemplate reads an embedded .agent.md template by phase name. +func ReadVSCodeAgentTemplate(phase string) (string, error) { + return assets.Read("vscode/agents/" + phase + ".agent.md") +} + +// ListVSCodeAgentTemplates returns the list of embedded VS Code agent template files. +func ListVSCodeAgentTemplates() ([]string, error) { + entries, err := fs.ReadDir(assets.FS, "vscode/agents") + if err != nil { + return nil, fmt.Errorf("read embedded vscode/agents dir: %w", err) + } + var names []string + for _, entry := range entries { + if !entry.IsDir() { + names = append(names, entry.Name()) + } + } + return names, nil +} \ No newline at end of file diff --git a/internal/agents/vscode/vscode_profiles_test.go b/internal/agents/vscode/vscode_profiles_test.go new file mode 100644 index 000000000..bb2778e10 --- /dev/null +++ b/internal/agents/vscode/vscode_profiles_test.go @@ -0,0 +1,286 @@ +package vscode + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gentleman-programming/gentle-ai/internal/model" +) + +func TestVSCodeModelID_KnownProviders(t *testing.T) { + tests := []struct { + name string + provider string + modelID string + want string + }{ + { + name: "anthropic claude-sonnet-4", + provider: "anthropic", + modelID: "claude-sonnet-4-20250514", + want: "Claude Sonnet 4 (copilot)", + }, + { + name: "anthropic claude-opus-4-5", + provider: "anthropic", + modelID: "claude-opus-4-5-20250514", + want: "Claude Opus 4.5 (copilot)", + }, + { + name: "anthropic claude-haiku-4-5", + provider: "anthropic", + modelID: "claude-haiku-4-5-20250514", + want: "Claude Haiku 4.5 (copilot)", + }, + { + name: "openai gpt-4o", + provider: "openai", + modelID: "gpt-4o-2024-11-20", + want: "GPT 4o (copilot)", + }, + { + name: "openai gpt-4o-mini", + provider: "openai", + modelID: "gpt-4o-mini", + want: "GPT 4o Mini (copilot)", + }, + { + name: "openai gpt-4.1", + provider: "openai", + modelID: "gpt-4.1-2025-04-14", + want: "GPT 4.1 (copilot)", + }, + { + name: "openai gpt-4.1-mini", + provider: "openai", + modelID: "gpt-4.1-mini", + want: "GPT 4.1 Mini (copilot)", + }, + { + name: "google gemini-2.5-pro", + provider: "google", + modelID: "gemini-2.5-pro-preview-05-06", + want: "Gemini 2.5 Pro (copilot)", + }, + { + name: "google gemini-2.5-flash", + provider: "google", + modelID: "gemini-2.5-flash-preview-05-20", + want: "Gemini 2.5 Flash (copilot)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := VSCodeModelID(model.ModelAssignment{ProviderID: tt.provider, ModelID: tt.modelID}) + if got != tt.want { + t.Fatalf("VSCodeModelID({%s, %s}) = %q, want %q", tt.provider, tt.modelID, got, tt.want) + } + }) + } +} + +func TestVSCodeModelID_UnknownProvider_FallsBack(t *testing.T) { + got := VSCodeModelID(model.ModelAssignment{ProviderID: "unknown", ModelID: "cheap-model"}) + if got != "unknown/cheap-model" { + t.Fatalf("VSCodeModelID({unknown, cheap-model}) = %q, want %q", got, "unknown/cheap-model") + } +} + +func TestVSCodeModelID_EmptyModelID_ReturnsEmpty(t *testing.T) { + got := VSCodeModelID(model.ModelAssignment{ProviderID: "anthropic", ModelID: ""}) + if got != "" { + t.Fatalf("VSCodeModelID({anthropic, ''}) = %q, want empty string", got) + } +} + +func TestGenerateAgentFile_DefaultProfile(t *testing.T) { + profile := model.Profile{ + Name: "", + PhaseAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "anthropic", ModelID: "claude-sonnet-4-20250514"}, + }, + } + + content := GenerateAgentFile("sdd-apply", profile) + + // Must have YAML frontmatter markers + if !strings.HasPrefix(content, "---\n") { + t.Fatalf("Agent file must start with YAML frontmatter, got: %q", content[:min(50, len(content))]) + } + if !strings.Contains(content, "\n---\n") { + t.Fatalf("Agent file must have closing YAML frontmatter marker") + } + + // Must contain required fields + if !strings.Contains(content, "name: sdd-apply") { + t.Fatalf("Agent file must contain 'name: sdd-apply', got:\n%s", content) + } + if !strings.Contains(content, "description:") { + t.Fatalf("Agent file must contain 'description:' field") + } + if !strings.Contains(content, "readonly:") { + t.Fatalf("Agent file must contain 'readonly:' field") + } + if !strings.Contains(content, "background:") { + t.Fatalf("Agent file must contain 'background:' field") + } + if !strings.Contains(content, "user-invocable:") { + t.Fatalf("Agent file must contain 'user-invocable:' field") + } + + // Must have model mapping for known provider + if !strings.Contains(content, "model: \"Claude Sonnet 4 (copilot)\"") { + t.Fatalf("Agent file must contain resolved model name, got:\n%s", content) + } +} + +func TestGenerateAgentFile_NamedProfile(t *testing.T) { + profile := model.Profile{ + Name: "cheap", + PhaseAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "openai", ModelID: "gpt-4o-mini"}, + }, + } + + content := GenerateAgentFile("sdd-apply", profile) + + // Named profile should include profile name in the agent name + if !strings.Contains(content, "name: sdd-apply-cheap") { + t.Fatalf("Named profile agent must have suffixed name, got:\n%s", content) + } +} + +func TestGenerateAgentFile_NoModelAssignment_OmitsField(t *testing.T) { + profile := model.Profile{ + Name: "", + PhaseAssignments: map[string]model.ModelAssignment{}, + } + + content := GenerateAgentFile("sdd-apply", profile) + + // When no model assignment for the phase, model field must be absent or omitted + frontmatterEnd := strings.Index(content[4:], "\n---\n") + if frontmatterEnd == -1 { + t.Fatalf("Cannot find closing frontmatter marker") + } + frontmatter := content[4 : frontmatterEnd+4] + + if strings.Contains(frontmatter, "model:") { + t.Fatalf("When no model assignment, model field must be absent from frontmatter, got:\n%s", frontmatter) + } +} + +func TestVSCodeModelID_AllDesignMappings(t *testing.T) { + // Verify every mapping from the design doc + designMappings := []struct { + modelSubstr string + expected string + }{ + {"claude-sonnet-4", "Claude Sonnet 4 (copilot)"}, + {"claude-opus-4-5", "Claude Opus 4.5 (copilot)"}, + {"claude-haiku-4-5", "Claude Haiku 4.5 (copilot)"}, + {"gemini-2.5-pro", "Gemini 2.5 Pro (copilot)"}, + {"gemini-2.5-flash", "Gemini 2.5 Flash (copilot)"}, + {"gpt-4.1", "GPT 4.1 (copilot)"}, + {"gpt-4.1-mini", "GPT 4.1 Mini (copilot)"}, + {"gpt-4o", "GPT 4o (copilot)"}, + {"gpt-4o-mini", "GPT 4o Mini (copilot)"}, + } + + for _, dm := range designMappings { + t.Run(dm.modelSubstr, func(t *testing.T) { + got := VSCodeModelID(model.ModelAssignment{ProviderID: "any", ModelID: dm.modelSubstr}) + if got != dm.expected { + t.Fatalf("VSCodeModelID({any, %s}) = %q, want %q", dm.modelSubstr, got, dm.expected) + } + }) + } +} + +func TestRemoveVSCodeProfileAgents_RemovesOnlyGentleAIAssets(t *testing.T) { + agentsDir := t.TempDir() + + // Create mixed files: gentle-ai SDD files + user files + gentleAISDD := []string{ + "sdd-init-cheap.agent.md", + "sdd-explore-cheap.agent.md", + "sdd-propose-cheap.agent.md", + "sdd-spec-cheap.agent.md", + "sdd-design-cheap.agent.md", + "sdd-tasks-cheap.agent.md", + "sdd-apply-cheap.agent.md", + "sdd-verify-cheap.agent.md", + "sdd-archive-cheap.agent.md", + "sdd-onboard-cheap.agent.md", + } + userFiles := []string{ + "my-custom.agent.md", + "helper.agent.md", + "notes.txt", + } + + for _, f := range gentleAISDD { + if err := os.WriteFile(filepath.Join(agentsDir, f), []byte("content"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", f, err) + } + } + for _, f := range userFiles { + if err := os.WriteFile(filepath.Join(agentsDir, f), []byte("user content"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", f, err) + } + } + + err := RemoveVSCodeProfileAgents(agentsDir, "cheap") + if err != nil { + t.Fatalf("RemoveVSCodeProfileAgents() error = %v", err) + } + + // All gentle-ai SDD files should be removed + for _, f := range gentleAISDD { + path := filepath.Join(agentsDir, f) + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Errorf("file %q should have been removed but still exists", f) + } + } + + // User files should be preserved + for _, f := range userFiles { + path := filepath.Join(agentsDir, f) + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + t.Errorf("user file %q should have been preserved but was removed", f) + } + } +} + +func TestRemoveVSCodeProfileAgents_RejectsDefaultProfile(t *testing.T) { + for _, name := range []string{"", "default"} { + t.Run(name, func(t *testing.T) { + err := RemoveVSCodeProfileAgents(t.TempDir(), name) + if err == nil { + t.Fatalf("RemoveVSCodeProfileAgents(%q) should return error for default profile", name) + } + if !strings.Contains(err.Error(), "cannot remove default profile") { + t.Fatalf("RemoveVSCodeProfileAgents(%q) error = %q, want 'cannot remove default profile'", name, err.Error()) + } + }) + } +} + +func TestRemoveVSCodeProfileAgents_SilentlySkipsMissingFiles(t *testing.T) { + agentsDir := t.TempDir() + // No files exist at all — should not error + err := RemoveVSCodeProfileAgents(agentsDir, "nonexistent") + if err != nil { + t.Fatalf("RemoveVSCodeProfileAgents() on empty dir error = %v", err) + } +} + +func TestRemoveVSCodeProfileAgents_NonexistentDirIsNoop(t *testing.T) { + err := RemoveVSCodeProfileAgents(filepath.Join(t.TempDir(), "does-not-exist"), "cheap") + if err != nil { + t.Fatalf("RemoveVSCodeProfileAgents() on nonexistent dir error = %v", err) + } +} \ No newline at end of file diff --git a/internal/assets/assets.go b/internal/assets/assets.go index f24bdae2e..1bddd8664 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 +//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:vscode 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 348f88ca5..7ed50bbe6 100644 --- a/internal/assets/assets_test.go +++ b/internal/assets/assets_test.go @@ -456,6 +456,46 @@ func TestOpenCodeSDDOverlaySubagentsAreExplicitExecutors(t *testing.T) { } } +func TestVSCodeAgentsEmbedded(t *testing.T) { + expectedAgents := []string{ + "vscode/agents/sdd-init.agent.md", + "vscode/agents/sdd-explore.agent.md", + "vscode/agents/sdd-propose.agent.md", + "vscode/agents/sdd-spec.agent.md", + "vscode/agents/sdd-design.agent.md", + "vscode/agents/sdd-tasks.agent.md", + "vscode/agents/sdd-apply.agent.md", + "vscode/agents/sdd-verify.agent.md", + "vscode/agents/sdd-archive.agent.md", + "vscode/agents/sdd-onboard.agent.md", + } + + for _, path := range expectedAgents { + t.Run(path, func(t *testing.T) { + content, err := Read(path) + if err != nil { + t.Fatalf("Read(%q) error = %v", path, err) + } + if len(strings.TrimSpace(content)) == 0 { + t.Fatalf("Read(%q) returned empty content", path) + } + if len(content) < 50 { + t.Fatalf("Read(%q) content is suspiciously short (%d bytes) — possible stub", path, len(content)) + } + // Must contain required YAML frontmatter fields + for _, field := range []string{"name:", "description:", "readonly:", "background:", "user-invocable:"} { + if !strings.Contains(content, field) { + t.Fatalf("Read(%q) missing required frontmatter field %q", path, field) + } + } + // Must contain {{VSC_MODEL}} placeholder for model resolution + if !strings.Contains(content, "{{VSC_MODEL}}") { + t.Fatalf("Read(%q) missing {{VSC_MODEL}} placeholder — model field must be dynamically resolved", path) + } + }) + } +} + func TestSDDOrchestratorAssetsScopedToDedicatedAgent(t *testing.T) { for _, assetPath := range []string{ "generic/sdd-orchestrator.md", 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..ac245a160 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-apply.agent.md @@ -0,0 +1,51 @@ +--- +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. +model: {{VSC_MODEL}} +readonly: false +background: false +user-invocable: false +--- + +You are the SDD **apply** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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`: `injected` if compact rules were provided in invocation message, otherwise `none` \ No newline at end of file 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..25a36e107 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-archive.agent.md @@ -0,0 +1,19 @@ +--- +name: sdd-archive +description: > + Sync delta specs and archive the completed change. Closes the SDD lifecycle. +model: {{VSC_MODEL}} +readonly: false +background: false +user-invocable: false +--- + +You are the SDD **archive** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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. \ No newline at end of file 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..14fea3534 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-design.agent.md @@ -0,0 +1,19 @@ +--- +name: sdd-design +description: > + Write the technical design and architecture approach for the change. +model: {{VSC_MODEL}} +readonly: false +background: false +user-invocable: false +--- + +You are the SDD **design** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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. \ No newline at end of file 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..ea6609232 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-explore.agent.md @@ -0,0 +1,20 @@ +--- +name: sdd-explore +description: > + Investigate ideas and approaches before committing to a change. Reads codebase, + compares approaches, produces exploration artifact. No files created. +model: {{VSC_MODEL}} +readonly: true +background: false +user-invocable: false +--- + +You are the SDD **explore** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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. \ No newline at end of file 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..1bafaef3d --- /dev/null +++ b/internal/assets/vscode/agents/sdd-init.agent.md @@ -0,0 +1,20 @@ +--- +name: sdd-init +description: > + Initialize SDD context for the project. Detects stack, bootstraps persistence, + and caches testing capabilities. +model: {{VSC_MODEL}} +readonly: false +background: false +user-invocable: false +--- + +You are the SDD **init** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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. \ No newline at end of file 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..88c65a777 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-onboard.agent.md @@ -0,0 +1,20 @@ +--- +name: sdd-onboard +description: > + Guided end-to-end walkthrough of SDD using the real codebase. Walks through + the full SDD cycle on an actual change to teach the workflow. +model: {{VSC_MODEL}} +readonly: false +background: false +user-invocable: false +--- + +You are the SDD **onboard** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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. \ No newline at end of file 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..b34b5d214 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-propose.agent.md @@ -0,0 +1,19 @@ +--- +name: sdd-propose +description: > + Draft the change proposal with intent, scope, and approach for a feature or bugfix. +model: {{VSC_MODEL}} +readonly: false +background: false +user-invocable: false +--- + +You are the SDD **propose** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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. \ No newline at end of file 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..ed1b787e8 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-spec.agent.md @@ -0,0 +1,19 @@ +--- +name: sdd-spec +description: > + Write requirements and acceptance scenarios for the proposed change. +model: {{VSC_MODEL}} +readonly: false +background: false +user-invocable: false +--- + +You are the SDD **spec** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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. \ No newline at end of file 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..77cb96ea8 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-tasks.agent.md @@ -0,0 +1,19 @@ +--- +name: sdd-tasks +description: > + Break down the change into implementation task checklist with workload forecast. +model: {{VSC_MODEL}} +readonly: false +background: false +user-invocable: false +--- + +You are the SDD **tasks** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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. \ No newline at end of file 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..b97c69b47 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-verify.agent.md @@ -0,0 +1,20 @@ +--- +name: sdd-verify +description: > + Validate implementation against specs, design, and tasks. Reports CRITICAL, WARNING, + and SUGGESTION findings. +model: {{VSC_MODEL}} +readonly: false +background: false +user-invocable: false +--- + +You are the SDD **verify** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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. \ No newline at end of file diff --git a/internal/components/sdd/inject.go b/internal/components/sdd/inject.go index b47fc1c80..dfdc977d9 100644 --- a/internal/components/sdd/inject.go +++ b/internal/components/sdd/inject.go @@ -13,6 +13,7 @@ import ( "github.com/gentleman-programming/gentle-ai/internal/assets" "github.com/gentleman-programming/gentle-ai/internal/components/filemerge" "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/agents/vscode" ) type InjectionResult struct { @@ -82,6 +83,16 @@ type claudeModelResolver interface { ClaudeModelID(alias model.ClaudeModelAlias) string } +// vscModelResolver is an optional adapter capability. When implemented, +// the subagent copy loop stamps the resolved model display name into the agent +// frontmatter sentinel {{VSC_MODEL}}. VS Code Copilot expects display names +// like "Claude Sonnet 4 (copilot)", not raw provider/model pairs. +// When the resolver returns empty string, the entire "model:" line is removed +// (Copilot falls back to its default model). +type vscModelResolver interface { + VSCModelID(m model.ModelAssignment) string +} + // monorepoRootMarkers identify files/dirs that ONLY exist at the true root // of a multi-package workspace. If any of these is found while walking up, // we stop immediately — this is the authoritative project root. @@ -428,6 +439,25 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt } } + // 2c. VS Code Copilot named SDD profiles → .agent.md files. + // The default (unsuffixed) set is handled by section 3c which copies the + // embedded templates directly. Named profiles are generated here using + // GenerateVSCodeProfileFiles which resolves model assignments dynamically. + if adapter.Agent() == model.AgentVSCodeCopilot && sddMode == model.SDDModeMulti && len(opts.Profiles) > 0 { + agentsDir := adapter.SubAgentsDir(homeDir) + for _, profile := range opts.Profiles { + if profile.Name == "" || profile.Name == "default" { + continue // default profile handled by 3c + } + profileFiles, profileErr := vscode.GenerateVSCodeProfileFiles(profile, agentsDir) + if profileErr != nil { + return InjectionResult{}, fmt.Errorf("generate VS Code profile %q: %w", profile.Name, profileErr) + } + changed = changed || len(profileFiles) > 0 + files = append(files, profileFiles...) + } + } + // 3. Write SDD skill files (if the agent supports skills). if adapter.SupportsSkills() { skillDir := adapter.SkillsDir(homeDir) @@ -600,6 +630,34 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt alias := resolveClaudeModelAlias(opts.ClaudeModelAssignments, phase) contentStr = strings.ReplaceAll(contentStr, "{{CLAUDE_MODEL}}", cmr.ClaudeModelID(alias)) } + + // Resolve {{VSC_MODEL}} placeholder for VS Code Copilot adapters. + // When VSCModelID returns empty string (no model assignment), remove + // the entire "model: {{VSC_MODEL}}" line so Copilot uses its default. + if vmr, ok := adapter.(vscModelResolver); ok { + // Trim the .agent.md or .md extension to get the phase name + phase := strings.TrimSuffix(entry.Name(), ".agent.md") + phase = strings.TrimSuffix(phase, ".md") + assignment := model.ModelAssignment{} + if opts.OpenCodeModelAssignments != nil { + if a, has := opts.OpenCodeModelAssignments[phase]; has { + assignment = a + } else if d, hasDefault := opts.OpenCodeModelAssignments["default"]; hasDefault { + assignment = d + } + } + resolved := vmr.VSCModelID(assignment) + if resolved == "" { + // Remove the model line entirely — Copilot falls back to default + contentStr = strings.ReplaceAll(contentStr, "model: {{VSC_MODEL}}\n", "") + } else { + contentStr = strings.ReplaceAll(contentStr, "{{VSC_MODEL}}", resolved) + } + } else if strings.Contains(contentStr, "{{VSC_MODEL}}") { + // Adapter doesn't resolve VSC_MODEL but template contains it; + // remove the model line so Copilot uses its default. + contentStr = strings.ReplaceAll(contentStr, "model: {{VSC_MODEL}}\n", "") + } outPath := filepath.Join(agentsDir, entry.Name()) writeResult, err := filemerge.WriteFileAtomic(outPath, []byte(contentStr), 0o644) if err != nil { @@ -611,10 +669,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 (supports .md, .yaml, and .agent.md extensions) for _, phase := range []string{"sdd-apply", "sdd-verify"} { found := false - for _, ext := range []string{".md", ".yaml"} { + for _, ext := range []string{".md", ".yaml", ".agent.md"} { checkPath := filepath.Join(agentsDir, phase+ext) if info, err := os.Stat(checkPath); err == nil && info.Size() >= 10 { found = true diff --git a/internal/components/sdd/vscode_inject_test.go b/internal/components/sdd/vscode_inject_test.go new file mode 100644 index 000000000..7ce32a793 --- /dev/null +++ b/internal/components/sdd/vscode_inject_test.go @@ -0,0 +1,164 @@ +package sdd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gentleman-programming/gentle-ai/internal/agents" + "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/agents/opencode" +) + +func TestInject_VSCodeSubAgents(t *testing.T) { + vscodeAdapter, err := agents.NewAdapter("vscode-copilot") + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + + home := t.TempDir() + + result, err := Inject(home, vscodeAdapter, model.SDDModeMulti) + if err != nil { + t.Fatalf("Inject() error = %v", err) + } + if !result.Changed { + t.Fatal("Inject() first run should report changed = true") + } + + agentsDir := vscodeAdapter.SubAgentsDir(home) + + // Verify agents dir was created + if _, statErr := os.Stat(agentsDir); os.IsNotExist(statErr) { + t.Fatalf("agents dir %q was not created", agentsDir) + } + + // Verify all 10 .agent.md files exist in the agents dir + expectedPhases := []string{ + "sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", + "sdd-design", "sdd-tasks", "sdd-apply", "sdd-verify", + "sdd-archive", "sdd-onboard", + } + + for _, phase := range expectedPhases { + t.Run(phase, func(t *testing.T) { + fileName := phase + ".agent.md" + path := filepath.Join(agentsDir, fileName) + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("ReadFile(%q) error = %v — sub-agent file should exist", path, readErr) + } + if len(data) < 10 { + t.Fatalf("sub-agent %q is too small (%d bytes), likely truncated", path, len(data)) + } + content := string(data) + if !strings.Contains(content, "name: "+phase) { + t.Fatalf("sub-agent %q missing name field for %s", path, phase) + } + }) + } + + // Verify post-check: sdd-apply.agent.md should exist and be non-trivial + applyPath := filepath.Join(agentsDir, "sdd-apply.agent.md") + applyData, applyErr := os.ReadFile(applyPath) + if applyErr != nil { + t.Fatalf("post-check: sdd-apply.agent.md not found: %v", applyErr) + } + if len(applyData) < 10 { + t.Fatalf("post-check: sdd-apply.agent.md is too small (%d bytes)", len(applyData)) + } +} + +func TestInject_VSCode_ImplicitFeatureFlag(t *testing.T) { + // Verify that inject only writes sub-agents when SupportsSubAgents() returns true. + // OpenCode does NOT support sub-agents (returns false) — no .agent.md files + // should be written to any agent agents directory by the 3c path. + opencodeAdapter := opencode.NewAdapter() + home := t.TempDir() + + _, err := Inject(home, opencodeAdapter, model.SDDModeMulti) + if err != nil { + t.Fatalf("Inject() error = %v", err) + } + + // VS Code agents directory should NOT have been created by OpenCode inject + vscodeAgentsDir := filepath.Join(home, ".copilot", "agents") + if _, statErr := os.Stat(vscodeAgentsDir); !os.IsNotExist(statErr) { + entries, readErr := os.ReadDir(vscodeAgentsDir) + if readErr != nil { + t.Fatalf("ReadDir(%q) error = %v", vscodeAgentsDir, readErr) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".agent.md") { + t.Fatalf("OpenCode injection should not write .agent.md files, found %q", entry.Name()) + } + } + } +} + +func TestPostInjectionValidation_VSCode(t *testing.T) { + vscodeAdapter, err := agents.NewAdapter("vscode-copilot") + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + + home := t.TempDir() + + // First injection should succeed and write files + _, err = Inject(home, vscodeAdapter, model.SDDModeMulti) + if err != nil { + t.Fatalf("Inject() error = %v", err) + } + + // Verify post-check catches a truncated sdd-apply file + agentsDir := vscodeAdapter.SubAgentsDir(home) + applyPath := filepath.Join(agentsDir, "sdd-apply.agent.md") + if err := os.WriteFile(applyPath, []byte("tiny"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", applyPath, err) + } + + // Re-inject should fix the truncated file (overwrites and passes post-check) + result, err := Inject(home, vscodeAdapter, model.SDDModeMulti) + if err != nil { + t.Fatalf("Re-inject after truncation error = %v", err) + } + // Re-injection should have fixed the file + _ = result + + data, readErr := os.ReadFile(applyPath) + if readErr != nil { + t.Fatalf("ReadFile(%q) after re-inject error = %v", applyPath, readErr) + } + if len(data) < 10 { + t.Fatalf("sdd-apply.agent.md still too small after re-injection (%d bytes)", len(data)) + } +} + +func TestPostInjectionValidation_VSCode_MissingFileDetected(t *testing.T) { + vscodeAdapter, err := agents.NewAdapter("vscode-copilot") + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + + home := t.TempDir() + + // First injection succeeds + _, err = Inject(home, vscodeAdapter, model.SDDModeMulti) + if err != nil { + t.Fatalf("Inject() error = %v", err) + } + + // Delete sdd-verify.agent.md to simulate a missing file + agentsDir := vscodeAdapter.SubAgentsDir(home) + verifyPath := filepath.Join(agentsDir, "sdd-verify.agent.md") + if err := os.Remove(verifyPath); err != nil { + t.Fatalf("Remove(%q) error = %v", verifyPath, err) + } + + // Re-inject should succeed (restores the missing file) + _, err = Inject(home, vscodeAdapter, model.SDDModeMulti) + if err != nil { + t.Fatalf("Re-inject after removing verify file error = %v", err) + } +} \ No newline at end of file From d26f0c7192541a8cb7ca0c2be922e24acc69d9ba Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 21:33:46 +0200 Subject: [PATCH 2/9] feat(tui): expose VS Code Copilot SDD profiles in welcome and profile screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the VS Code Copilot SDD multi-mode profiles reachable from the gentle-ai wizard, mirroring the OpenCode profile flow. - New backend helper `vscode.DetectVSCodeProfiles(agentsDir)` scans `~/.copilot/agents/sdd-*-{name}.agent.md` and returns deduplicated profile names. Missing directory is not an error. - New `Model.hasDetectedVSCode()`, `Model.VSCodeProfileList`, and `Model.ActiveProfileAdapter` thread the active adapter through the shared profile screens. - Welcome menu shows a new "VS Code SDD Profiles (N)" entry next to the existing OpenCode one when VS Code Copilot is detected. - `RenderProfiles` and `RenderProfileDelete` take an adapter label / isVSCode flag and adapt their wording (no more hardcoded "OpenCode SDD Profiles" / "opencode.json"). - Profile create for VS Code uses a new `VSCodeModelPickerState` driven by the static `vscModelEntries` table (9 known Copilot models). Per-phase model assignment works exactly as for OpenCode, with the model field resolved via `VSCodeModelID`. - Profile create/delete for VS Code bypass the sync pipeline and call `vscode.GenerateVSCodeProfileFiles` / `vscode.RemoveVSCodeProfileAgents` directly — VS Code profiles are file-based, not JSON-merged. - `SyncDoneMsg` and `ScreenProfiles` entry refresh both profile lists so badges stay accurate across both adapters. Test coverage: - `internal/agents/vscode/vscode_profiles_detect_test.go` — 5 cases (happy path, empty/missing dir, non-SDD files filtered, default unsuffixed files excluded). - `internal/tui/model_profiles_vscode_test.go` — TUI integration tests for adapter detection, menu rendering, ActiveProfileAdapter routing, file generation, deletion (no sync), and adapter-aware rendering for both OpenCode and VS Code. Stacked on feat/vscode-copilot-sdd-multimode (PR #1 backend). --- internal/agents/vscode/vscode_profiles.go | 87 +++++ .../vscode/vscode_profiles_detect_test.go | 116 ++++++ internal/tui/model.go | 291 ++++++++++++--- internal/tui/model_profiles_vscode_test.go | 344 ++++++++++++++++++ internal/tui/model_test.go | 4 +- internal/tui/screens/profile_delete.go | 53 ++- internal/tui/screens/profile_delete_test.go | 10 +- internal/tui/screens/profiles.go | 19 +- internal/tui/screens/profiles_test.go | 12 +- internal/tui/screens/vscode_model_picker.go | 320 ++++++++++++++++ internal/tui/screens/welcome.go | 22 +- internal/tui/screens/welcome_test.go | 26 +- 12 files changed, 1195 insertions(+), 109 deletions(-) create mode 100644 internal/agents/vscode/vscode_profiles_detect_test.go create mode 100644 internal/tui/model_profiles_vscode_test.go create mode 100644 internal/tui/screens/vscode_model_picker.go diff --git a/internal/agents/vscode/vscode_profiles.go b/internal/agents/vscode/vscode_profiles.go index fcb894f28..ae5ea3522 100644 --- a/internal/agents/vscode/vscode_profiles.go +++ b/internal/agents/vscode/vscode_profiles.go @@ -5,6 +5,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" "strings" "github.com/gentleman-programming/gentle-ai/internal/assets" @@ -206,6 +207,92 @@ func RemoveVSCodeProfileAgents(agentsDir, profileName string) error { return nil } +// DetectVSCodeProfiles scans agentsDir for sdd-{phase}-{name}.agent.md files +// and returns deduplicated, sorted []model.Profile. An empty or missing +// directory is not an error — callers treat it as "no profiles yet". +func DetectVSCodeProfiles(agentsDir string) ([]model.Profile, error) { + entries, err := os.ReadDir(agentsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read agents dir: %w", err) + } + + seen := make(map[string]struct{}) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + // Must match sdd-{phase}-{profileName}.agent.md + // Strategy: strip known phase prefixes and .agent.md suffix to extract profile name. + profileName := extractProfileName(name) + if profileName == "" { + continue + } + seen[profileName] = struct{}{} + } + + if len(seen) == 0 { + return nil, nil + } + + profiles := make([]model.Profile, 0, len(seen)) + for name := range seen { + profiles = append(profiles, model.Profile{Name: name}) + } + + // Sort deterministically by name. + sort.Slice(profiles, func(i, j int) bool { + return profiles[i].Name < profiles[j].Name + }) + + return profiles, nil +} + +// extractProfileName parses a filename of the form sdd-{phase}-{profileName}.agent.md +// and returns the profile name. Returns "" if the file does not match the pattern. +func extractProfileName(filename string) string { + const suffix = ".agent.md" + if !strings.HasSuffix(filename, suffix) { + return "" + } + if !strings.HasPrefix(filename, "sdd-") { + return "" + } + // Strip suffix + base := filename[:len(filename)-len(suffix)] + // Try to match sdd-{phase}-{name}: look for a known phase prefix + for _, phase := range sddPhases { + phasePrefix := phase + "-" + if strings.HasPrefix(base, phasePrefix) { + profileName := base[len(phasePrefix):] + if profileName != "" { + return profileName + } + } + } + return "" +} + +// VSCodeStaticModels returns the static list of VS Code Copilot model entries +// as (modelSubstr, displayName) pairs for the TUI model picker. +// The order matches vscModelEntries — most specific entries first. +func VSCodeStaticModels() []VSCodeModelEntry { + result := make([]VSCodeModelEntry, len(vscModelEntries)) + for i, e := range vscModelEntries { + result[i] = VSCodeModelEntry{ModelSubstr: e.substr, DisplayName: e.display} + } + return result +} + +// VSCodeModelEntry is a public representation of one VS Code model option. +type VSCodeModelEntry struct { + ModelSubstr string // model ID substring used for matching + DisplayName string // human-friendly display name shown in the TUI +} + // ReadVSCodeAgentTemplate reads an embedded .agent.md template by phase name. func ReadVSCodeAgentTemplate(phase string) (string, error) { return assets.Read("vscode/agents/" + phase + ".agent.md") diff --git a/internal/agents/vscode/vscode_profiles_detect_test.go b/internal/agents/vscode/vscode_profiles_detect_test.go new file mode 100644 index 000000000..73d65f1b1 --- /dev/null +++ b/internal/agents/vscode/vscode_profiles_detect_test.go @@ -0,0 +1,116 @@ +package vscode + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectVSCodeProfiles_HappyPath(t *testing.T) { + agentsDir := t.TempDir() + + // Write 10 sdd-*-cheap.agent.md + 10 sdd-*-fast.agent.md files + for _, phase := range sddPhases { + for _, profileName := range []string{"cheap", "fast"} { + fname := phase + "-" + profileName + ".agent.md" + if err := os.WriteFile(filepath.Join(agentsDir, fname), []byte("content"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", fname, err) + } + } + } + + profiles, err := DetectVSCodeProfiles(agentsDir) + if err != nil { + t.Fatalf("DetectVSCodeProfiles() error = %v", err) + } + if len(profiles) != 2 { + t.Fatalf("DetectVSCodeProfiles() returned %d profiles, want 2", len(profiles)) + } + // Must be sorted by name + if profiles[0].Name != "cheap" { + t.Errorf("profiles[0].Name = %q, want %q", profiles[0].Name, "cheap") + } + if profiles[1].Name != "fast" { + t.Errorf("profiles[1].Name = %q, want %q", profiles[1].Name, "fast") + } +} + +func TestDetectVSCodeProfiles_EmptyDir(t *testing.T) { + agentsDir := t.TempDir() + + profiles, err := DetectVSCodeProfiles(agentsDir) + if err != nil { + t.Fatalf("DetectVSCodeProfiles() on empty dir error = %v", err) + } + if len(profiles) != 0 { + t.Fatalf("DetectVSCodeProfiles() returned %d profiles on empty dir, want 0", len(profiles)) + } +} + +func TestDetectVSCodeProfiles_MissingDir(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist") + + profiles, err := DetectVSCodeProfiles(missing) + if err != nil { + t.Fatalf("DetectVSCodeProfiles() on missing dir error = %v (want nil)", err) + } + if len(profiles) != 0 { + t.Fatalf("DetectVSCodeProfiles() returned %d profiles on missing dir, want 0", len(profiles)) + } +} + +func TestDetectVSCodeProfiles_IgnoresNonSDDFiles(t *testing.T) { + agentsDir := t.TempDir() + + // Write non-SDD files that must be ignored + noiseFiles := []string{ + "my-custom.agent.md", + ".DS_Store", + "notes.txt", + "readme.md", + } + for _, f := range noiseFiles { + if err := os.WriteFile(filepath.Join(agentsDir, f), []byte("noise"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", f, err) + } + } + // Write one valid sdd profile file + if err := os.WriteFile(filepath.Join(agentsDir, "sdd-apply-myprofile.agent.md"), []byte("content"), 0o644); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + profiles, err := DetectVSCodeProfiles(agentsDir) + if err != nil { + t.Fatalf("DetectVSCodeProfiles() error = %v", err) + } + if len(profiles) != 1 { + t.Fatalf("DetectVSCodeProfiles() returned %d profiles, want 1", len(profiles)) + } + if profiles[0].Name != "myprofile" { + t.Errorf("profiles[0].Name = %q, want %q", profiles[0].Name, "myprofile") + } +} + +func TestDetectVSCodeProfiles_DefaultFilesExcluded(t *testing.T) { + agentsDir := t.TempDir() + + // Unsuffixed default files must NOT be counted as profiles + defaultFiles := []string{ + "sdd-apply.agent.md", + "sdd-verify.agent.md", + "sdd-init.agent.md", + } + for _, f := range defaultFiles { + if err := os.WriteFile(filepath.Join(agentsDir, f), []byte("default"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", f, err) + } + } + + profiles, err := DetectVSCodeProfiles(agentsDir) + if err != nil { + t.Fatalf("DetectVSCodeProfiles() error = %v", err) + } + if len(profiles) != 0 { + t.Fatalf("DetectVSCodeProfiles() returned %d profiles for default files only, want 0", len(profiles)) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index fead87e9b..b6092f679 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/gentleman-programming/gentle-ai/internal/agentbuilder" + "github.com/gentleman-programming/gentle-ai/internal/agents/vscode" "github.com/gentleman-programming/gentle-ai/internal/backup" "github.com/gentleman-programming/gentle-ai/internal/catalog" "github.com/gentleman-programming/gentle-ai/internal/components/opencodeplugin" @@ -51,6 +52,18 @@ var readProfilesFn = func(settingsPath string) ([]model.Profile, error) { return sdd.DetectProfiles(settingsPath) } +// readVSCodeProfilesFn is a package-level variable so tests can override how +// VS Code profiles are detected from the agents directory. +var readVSCodeProfilesFn = func(agentsDir string) ([]model.Profile, error) { + return vscode.DetectVSCodeProfiles(agentsDir) +} + +// vscodeAgentsDirFn returns the path to the VS Code Copilot agents directory. +// Package-level so tests can override it. +var vscodeAgentsDirFn = func() string { + return filepath.Join(homeDir(), ".copilot", "agents") +} + // TickMsg drives the spinner animation on the installing screen. type TickMsg time.Time @@ -248,9 +261,10 @@ type Model struct { Progress ProgressState Execution pipeline.ExecutionResult Backups []backup.Manifest - ModelPicker screens.ModelPickerState - ClaudeModelPicker screens.ClaudeModelPickerState - KiroModelPicker screens.KiroModelPickerState + ModelPicker screens.ModelPickerState + VSCodeModelPicker screens.VSCodeModelPickerState + ClaudeModelPicker screens.ClaudeModelPickerState + KiroModelPicker screens.KiroModelPickerState SkillPicker []model.SkillID Err error @@ -365,6 +379,14 @@ type Model struct { ProfileNameCollision bool // true when name collides with existing profile (awaiting second enter to overwrite) ProfileDeleteErr error // error from the last RemoveProfileAgents call, displayed on ScreenProfiles + // VSCodeProfileList holds the VS Code SDD profiles detected from ~/.copilot/agents/. + VSCodeProfileList []model.Profile + + // ActiveProfileAdapter identifies which adapter's profile screen is currently + // shown. Set when the user selects a profiles entry from the welcome menu. + // Empty means OpenCode (the default); model.AgentVSCodeCopilot means VS Code. + ActiveProfileAdapter model.AgentID + // UninstallMode holds the selected uninstall mode (partial, full, full-remove). UninstallMode model.UninstallMode @@ -557,6 +579,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } // else keep existing list + // Sync doesn't change VS Code files, but refresh the list cheaply to keep + // it in sync with any out-of-band changes. + if m.hasDetectedVSCode() { + if vscProfiles, err := readVSCodeProfilesFn(vscodeAgentsDirFn()); err == nil { + m.VSCodeProfileList = vscProfiles + } + } return m, nil case UninstallDoneMsg: m.OperationRunning = false @@ -688,7 +717,7 @@ func (m Model) View() string { if m.UpdateCheckDone && update.HasUpdates(m.UpdateResults) { banner = "Updates available: " + update.UpdateSummaryLine(m.UpdateResults) } - return screens.RenderWelcome(m.Cursor, m.Version, banner, m.UpdateResults, m.UpdateCheckDone, m.hasDetectedOpenCode(), len(m.ProfileList), m.hasAgentBuilderEngines()) + return screens.RenderWelcome(m.Cursor, m.Version, banner, m.UpdateResults, m.UpdateCheckDone, m.hasDetectedOpenCode(), len(m.ProfileList), m.hasAgentBuilderEngines(), m.hasDetectedVSCode(), len(m.VSCodeProfileList)) case ScreenUpgrade: return screens.RenderUpgrade(m.UpdateResults, m.UpgradeReport, m.UpgradeErr, m.OperationRunning, m.UpdateCheckDone, m.Cursor, m.SpinnerFrame) case ScreenSync: @@ -696,8 +725,22 @@ func (m Model) View() string { case ScreenModelConfig: return screens.RenderModelConfig(m.Cursor) case ScreenProfiles: - return screens.RenderProfiles(m.ProfileList, m.Cursor, m.ProfileDeleteErr) + profiles, adapterLabel := m.activeProfiles() + return screens.RenderProfiles(profiles, m.Cursor, m.ProfileDeleteErr, adapterLabel) case ScreenProfileCreate: + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + return screens.RenderVSCodeProfileCreate( + m.ProfileCreateStep, + m.ProfileDraft, + m.ProfileNameInput, + m.ProfileNamePos, + m.ProfileNameErr, + m.ProfileEditMode, + m.Selection.ModelAssignments, + m.VSCodeModelPicker, + m.Cursor, + ) + } return screens.RenderProfileCreate( m.ProfileCreateStep, m.ProfileDraft, @@ -710,7 +753,7 @@ func (m Model) View() string { m.Cursor, ) case ScreenProfileDelete: - return screens.RenderProfileDelete(m.ProfileDeleteTarget, m.Cursor) + return screens.RenderProfileDelete(m.ProfileDeleteTarget, m.Cursor, m.ActiveProfileAdapter == model.AgentVSCodeCopilot) case ScreenUpgradeSync: return screens.RenderUpgradeSync(m.UpdateResults, m.UpgradeReport, m.SyncFilesChanged, m.UpgradeErr, m.SyncErr, m.OperationRunning, m.UpdateCheckDone, m.Cursor, m.SpinnerFrame) case ScreenUninstallMode: @@ -813,13 +856,22 @@ func (m Model) handleKeyPress(key tea.KeyMsg) (tea.Model, tea.Cmd) { } } - // Profile create step 1 reuses the ModelPicker sub-modes (provider/model drill-down). - if (m.Screen == ScreenProfileCreate && m.ProfileCreateStep == 1) && - m.ModelPicker.Mode != screens.ModePhaseList { - handled, updated := screens.HandleModelPickerNav(keyStr, &m.ModelPicker, m.Selection.ModelAssignments) - if handled { - m.Selection.ModelAssignments = updated - return m, nil + // Profile create step 1 — delegate to the correct model picker sub-mode. + if m.Screen == ScreenProfileCreate && m.ProfileCreateStep == 1 { + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + if m.VSCodeModelPicker.Mode != screens.ModePhaseList { + handled, updated := screens.HandleVSCodeModelPickerNav(keyStr, &m.VSCodeModelPicker, m.Selection.ModelAssignments) + if handled { + m.Selection.ModelAssignments = updated + return m, nil + } + } + } else if m.ModelPicker.Mode != screens.ModePhaseList { + handled, updated := screens.HandleModelPickerNav(keyStr, &m.ModelPicker, m.Selection.ModelAssignments) + if handled { + m.Selection.ModelAssignments = updated + return m, nil + } } } @@ -1133,6 +1185,20 @@ func (m Model) confirmSelection() (tea.Model, tea.Cmd) { if m.hasDetectedOpenCode() { if m.Cursor == next { + m.ActiveProfileAdapter = model.AgentOpenCode + m.setScreen(ScreenProfiles) + return m, nil + } + next++ + } + + if m.hasDetectedVSCode() { + if m.Cursor == next { + m.ActiveProfileAdapter = model.AgentVSCodeCopilot + // Refresh VS Code profile list on entry. + if profiles, err := readVSCodeProfilesFn(vscodeAgentsDirFn()); err == nil { + m.VSCodeProfileList = profiles + } m.setScreen(ScreenProfiles) return m, nil } @@ -1324,29 +1390,37 @@ func (m Model) confirmSelection() (tea.Model, tea.Cmd) { m.OperationMode = "upgrade-sync" return m, tea.Batch(tickCmd(), m.startUpgradeSync()) case ScreenProfiles: - // Profiles are: 0..len(ProfileList)-1, then Create, then Back. - profileCount := len(m.ProfileList) + // Profiles are: 0..len(profiles)-1, then Create, then Back. + profiles, _ := m.activeProfiles() + profileCount := len(profiles) switch { case m.Cursor < profileCount: // Edit an existing profile. - profile := m.ProfileList[m.Cursor] + profile := profiles[m.Cursor] m.ProfileEditMode = true m.ProfileDraft = profile m.ProfileCreateStep = 0 m.ProfileNameInput = profile.Name m.ProfileNamePos = len([]rune(profile.Name)) m.ProfileNameErr = "" - // Build ModelAssignments from the profile's phase assignments + orchestrator. - // The ModelPicker shows gentle-orchestrator as the base row, so we need - // to include it in the map for it to display the current model. - assignments := make(map[string]model.ModelAssignment) - for k, v := range profile.PhaseAssignments { - assignments[k] = v - } - if profile.OrchestratorModel.ProviderID != "" { - assignments[screens.SDDOrchestratorPhase] = profile.OrchestratorModel + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + // VS Code edit: no orchestrator model, just phase assignments. + assignments := make(map[string]model.ModelAssignment) + for k, v := range profile.PhaseAssignments { + assignments[k] = v + } + m.Selection.ModelAssignments = assignments + } else { + // OpenCode: include orchestrator model in assignments for the picker. + assignments := make(map[string]model.ModelAssignment) + for k, v := range profile.PhaseAssignments { + assignments[k] = v + } + if profile.OrchestratorModel.ProviderID != "" { + assignments[screens.SDDOrchestratorPhase] = profile.OrchestratorModel + } + m.Selection.ModelAssignments = assignments } - m.Selection.ModelAssignments = assignments m.setScreen(ScreenProfileCreate) case m.Cursor == profileCount: // "Create new profile" @@ -1367,17 +1441,33 @@ func (m Model) confirmSelection() (tea.Model, tea.Cmd) { return m.confirmProfileCreate() case ScreenProfileDelete: switch m.Cursor { - case 0: // "Delete & Sync" - if err := sdd.RemoveProfileAgents(opencode.DefaultSettingsPath(), m.ProfileDeleteTarget); err != nil { - // Store the error so it can be displayed on ScreenProfiles. - m.ProfileDeleteErr = err + case 0: // "Delete & Sync" (OpenCode) / "Delete" (VS Code) + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + // VS Code: remove agent files directly, no sync needed. + if err := vscode.RemoveVSCodeProfileAgents(vscodeAgentsDirFn(), m.ProfileDeleteTarget); err != nil { + m.ProfileDeleteErr = err + m.setScreen(ScreenProfiles) + return m, nil + } + m.ProfileDeleteErr = nil + // Refresh VS Code profile list. + if profiles, err := readVSCodeProfilesFn(vscodeAgentsDirFn()); err == nil { + m.VSCodeProfileList = profiles + } m.setScreen(ScreenProfiles) } else { - m.ProfileDeleteErr = nil - m.PendingSyncOverrides = nil - m = m.withResetSyncState() - m.setScreen(ScreenSync) - return m, tea.Batch(tickCmd(), m.startSync(nil)) + // OpenCode: sync pipeline. + if err := sdd.RemoveProfileAgents(opencode.DefaultSettingsPath(), m.ProfileDeleteTarget); err != nil { + // Store the error so it can be displayed on ScreenProfiles. + m.ProfileDeleteErr = err + m.setScreen(ScreenProfiles) + } else { + m.ProfileDeleteErr = nil + m.PendingSyncOverrides = nil + m = m.withResetSyncState() + m.setScreen(ScreenSync) + return m, tea.Batch(tickCmd(), m.startSync(nil)) + } } default: // "Cancel" m.setScreen(ScreenProfiles) @@ -2541,18 +2631,27 @@ func (m *Model) setScreen(next Screen) { if next == ScreenProfiles { // Clear stale delete error so it is not shown after Cancel/Esc from ScreenProfileDelete. m.ProfileDeleteErr = nil - // Refresh profile list on entry. Surface errors via m.Err so callers can react. - profiles, err := readProfilesFn(opencode.DefaultSettingsPath()) - if err != nil { - m.Err = err - m.ProfileList = nil + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + // Refresh VS Code profile list on entry. + if profiles, err := readVSCodeProfilesFn(vscodeAgentsDirFn()); err == nil { + m.VSCodeProfileList = profiles + } + if m.Cursor >= len(m.VSCodeProfileList) { + m.Cursor = 0 + } } else { - m.ProfileList = profiles - } - // Clamp cursor so it never points past the end of a refreshed list. - // m.Cursor was just reset to 0 above, so this only triggers if ProfileList is empty. - if m.Cursor >= len(m.ProfileList) { - m.Cursor = 0 + // Refresh OpenCode profile list on entry. + profiles, err := readProfilesFn(opencode.DefaultSettingsPath()) + if err != nil { + m.Err = err + m.ProfileList = nil + } else { + m.ProfileList = profiles + } + // Clamp cursor so it never points past the end of a refreshed list. + if m.Cursor >= len(m.ProfileList) { + m.Cursor = 0 + } } } if next == ScreenUninstallMode { @@ -2613,7 +2712,7 @@ func (m Model) handleRenameInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) optionCount() int { switch m.Screen { case ScreenWelcome: - return len(screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, m.hasDetectedOpenCode(), len(m.ProfileList), m.hasAgentBuilderEngines())) + return len(screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, m.hasDetectedOpenCode(), len(m.ProfileList), m.hasAgentBuilderEngines(), m.hasDetectedVSCode(), len(m.VSCodeProfileList))) case ScreenUpgrade: if m.UpgradeReport != nil || m.UpgradeErr != nil { return 1 // "return" option in results/error state @@ -2695,8 +2794,12 @@ func (m Model) optionCount() int { case ScreenRenameBackup: return 0 // text input mode — no cursor navigation case ScreenProfiles: - return screens.ProfileListOptionCount(m.ProfileList) + profiles, _ := m.activeProfiles() + return screens.ProfileListOptionCount(profiles) case ScreenProfileCreate: + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + return screens.VSCodeProfileCreateOptionCount(m.ProfileCreateStep) + } return screens.ProfileCreateOptionCount(m.ProfileCreateStep, m.ModelPicker) case ScreenProfileDelete: return screens.ProfileDeleteOptionCount() @@ -3145,6 +3248,25 @@ func (m Model) hasDetectedOpenCode() bool { return false } +// hasDetectedVSCode returns true if VS Code Copilot config directory was detected. +func (m Model) hasDetectedVSCode() bool { + for _, cfg := range m.Detection.Configs { + if cfg.Agent == string(model.AgentVSCodeCopilot) && cfg.Exists { + return true + } + } + return false +} + +// activeProfiles returns the profile list and display label for the currently +// active adapter. Used by View() and optionCount() to branch on adapter. +func (m Model) activeProfiles() ([]model.Profile, string) { + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + return m.VSCodeProfileList, "VS Code" + } + return m.ProfileList, "OpenCode" +} + func (m Model) shouldShowSDDModeScreen() bool { return m.Selection.HasAgent(model.AgentOpenCode) && hasSelectedComponent(m.Selection.Components, model.ComponentSDD) @@ -3333,6 +3455,44 @@ func (m Model) handleProfileNameInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// confirmVSCodeProfileCreateStep1 handles enter on step 1 of the VS Code profile +// create flow. Uses VSCodeModelPicker (static flat model list, no orchestrator row). +func (m Model) confirmVSCodeProfileCreateStep1() (tea.Model, tea.Cmd) { + rows := screens.VSCodeModelRows() + if m.Cursor < len(rows) { + // Enter model select for the chosen phase row. + m.VSCodeModelPicker.SelectedPhaseIdx = m.Cursor + m.VSCodeModelPicker.Mode = screens.ModeModelSelect + m.VSCodeModelPicker.ModelCursor = 0 + m.VSCodeModelPicker.ModelScroll = 0 + return m, nil + } + if m.Cursor == len(rows) { + // "Continue": copy phase assignments to draft, advance. + if m.Selection.ModelAssignments != nil { + if m.ProfileDraft.PhaseAssignments == nil { + m.ProfileDraft.PhaseAssignments = make(map[string]model.ModelAssignment) + } + for k, v := range m.Selection.ModelAssignments { + m.ProfileDraft.PhaseAssignments[k] = v + } + } + m.ProfileCreateStep = 2 + m.Cursor = 0 + return m, nil + } + if m.Cursor == len(rows)+1 { + // "Back" + if m.ProfileEditMode { + m.setScreen(ScreenProfiles) + } else { + m.ProfileCreateStep = 0 + m.Cursor = 0 + } + } + return m, nil +} + // confirmProfileCreate handles enter key presses on ScreenProfileCreate. // Step 0 (name input) is handled by handleProfileNameInput for create mode. // Steps: 0=name, 1=assign models (orchestrator + sub-agents), 2=confirm. @@ -3342,18 +3502,24 @@ func (m Model) confirmProfileCreate() (tea.Model, tea.Cmd) { // Edit mode: step 0 shows read-only name, enter advances to step 1. if m.ProfileEditMode { m.ProfileCreateStep = 1 - cachePath := opencode.DefaultCachePath() - if _, err := osStatModelCache(cachePath); err == nil { - m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + m.VSCodeModelPicker = screens.VSCodeModelPickerState{} } else { - m.ModelPicker = screens.ModelPickerState{} + cachePath := opencode.DefaultCachePath() + if _, err := osStatModelCache(cachePath); err == nil { + m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) + } else { + m.ModelPicker = screens.ModelPickerState{} + } } m.Cursor = 0 } return m, nil case 1: - // Model assignment picker: orchestrator + all sub-agent phases in one screen. - // Reuse the same enter-on-row logic as ScreenModelPicker. + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + return m.confirmVSCodeProfileCreateStep1() + } + // OpenCode: model assignment picker with orchestrator + sub-agent phases. rows := screens.ModelPickerRows() if m.Cursor < len(rows) { // Enter sub-selection: pick provider then model. @@ -3396,8 +3562,23 @@ func (m Model) confirmProfileCreate() (tea.Model, tea.Cmd) { default: // Step 2: confirm. switch m.Cursor { - case 0: // "Create & Sync" / "Save & Sync" + case 0: // "Create & Sync" / "Save & Sync" / "Create" (VS Code) draft := m.ProfileDraft + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + // VS Code: write files directly, no sync needed. + if _, err := vscode.GenerateVSCodeProfileFiles(draft, vscodeAgentsDirFn()); err != nil { + m.ProfileDeleteErr = err + m.setScreen(ScreenProfiles) + return m, nil + } + // Refresh VS Code profile list. + if profiles, err := readVSCodeProfilesFn(vscodeAgentsDirFn()); err == nil { + m.VSCodeProfileList = profiles + } + m.setScreen(ScreenProfiles) + return m, nil + } + // OpenCode: sync pipeline. m.PendingSyncOverrides = &model.SyncOverrides{ Profiles: []model.Profile{draft}, } diff --git a/internal/tui/model_profiles_vscode_test.go b/internal/tui/model_profiles_vscode_test.go new file mode 100644 index 000000000..371e8be99 --- /dev/null +++ b/internal/tui/model_profiles_vscode_test.go @@ -0,0 +1,344 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/system" + "github.com/gentleman-programming/gentle-ai/internal/tui/screens" +) + +// newModelWithVSCodeDetected returns a Model that reports VS Code Copilot as detected. +func newModelWithVSCodeDetected() Model { + m := NewModel(system.DetectionResult{ + Configs: []system.ConfigState{ + {Agent: string(model.AgentVSCodeCopilot), Exists: true}, + }, + }, "dev") + return m +} + +// newModelWithBothDetected returns a Model with both OpenCode and VS Code detected. +func newModelWithBothDetected() Model { + m := NewModel(system.DetectionResult{ + Configs: []system.ConfigState{ + {Agent: string(model.AgentOpenCode), Exists: true}, + {Agent: string(model.AgentVSCodeCopilot), Exists: true}, + }, + }, "dev") + return m +} + +// TestHasDetectedVSCode verifies the detection flag based on Detection.Configs. +func TestHasDetectedVSCode(t *testing.T) { + tests := []struct { + name string + configs []system.ConfigState + want bool + }{ + { + name: "vscode detected and exists", + configs: []system.ConfigState{{Agent: string(model.AgentVSCodeCopilot), Exists: true}}, + want: true, + }, + { + name: "vscode present but not exists", + configs: []system.ConfigState{{Agent: string(model.AgentVSCodeCopilot), Exists: false}}, + want: false, + }, + { + name: "opencode detected, not vscode", + configs: []system.ConfigState{{Agent: string(model.AgentOpenCode), Exists: true}}, + want: false, + }, + { + name: "empty configs", + configs: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewModel(system.DetectionResult{Configs: tt.configs}, "dev") + if got := m.hasDetectedVSCode(); got != tt.want { + t.Errorf("hasDetectedVSCode() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestWelcomeMenuShowsVSCodeProfiles verifies that the VS Code profile entry +// appears in the welcome menu when VS Code is detected. +func TestWelcomeMenuShowsVSCodeProfiles(t *testing.T) { + m := newModelWithVSCodeDetected() + m.VSCodeProfileList = []model.Profile{{Name: "cheap"}, {Name: "fast"}} + + view := m.View() + + if !strings.Contains(view, "VS Code SDD Profiles (2)") { + t.Errorf("welcome view missing 'VS Code SDD Profiles (2)', got:\n%s", view) + } +} + +// TestWelcomeMenuHidesVSCodeProfilesWhenNotDetected ensures the entry is absent +// when VS Code is not detected. +func TestWelcomeMenuHidesVSCodeProfilesWhenNotDetected(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.VSCodeProfileList = nil + + view := m.View() + + if strings.Contains(view, "VS Code SDD Profiles") { + t.Errorf("welcome view should NOT contain 'VS Code SDD Profiles' when not detected, got:\n%s", view) + } +} + +// TestActiveProfileAdapter_SetOnWelcomeClick verifies that clicking the VS Code profiles +// menu item sets ActiveProfileAdapter to AgentVSCodeCopilot and transitions to ScreenProfiles. +func TestActiveProfileAdapter_SetOnWelcomeClick(t *testing.T) { + m := newModelWithVSCodeDetected() + m.Screen = ScreenWelcome + + // Compute which cursor index is the VS Code profiles entry. + // Menu: 0=Install, 1=Upgrade, 2=Sync, 3=Upgrade+Sync, 4=ModelConfig, + // 5=AgentBuilder, 6=Plugins, 7=VSCodeProfiles(since OpenCode not detected), 8=Backups, 9=Uninstall, 10=Quit + opts := screens.WelcomeOptions(nil, false, false, 0, false, true, 0) + vscodeIdx := -1 + for i, opt := range opts { + if strings.HasPrefix(opt, "VS Code SDD Profiles") { + vscodeIdx = i + break + } + } + if vscodeIdx == -1 { + t.Fatal("VS Code SDD Profiles not found in WelcomeOptions") + } + + m.Cursor = vscodeIdx + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + if state.Screen != ScreenProfiles { + t.Errorf("screen = %v, want ScreenProfiles", state.Screen) + } + if state.ActiveProfileAdapter != model.AgentVSCodeCopilot { + t.Errorf("ActiveProfileAdapter = %q, want %q", state.ActiveProfileAdapter, model.AgentVSCodeCopilot) + } +} + +// TestVSCodeProfileCreate_GeneratesFiles verifies that confirming a VS Code profile +// create writes 10 agent files and refreshes VSCodeProfileList (no sync triggered). +func TestVSCodeProfileCreate_GeneratesFiles(t *testing.T) { + agentsDir := t.TempDir() + + // Override the readVSCodeProfilesFn so it reads from our temp dir. + restore := overrideReadVSCodeProfilesFn(agentsDir) + defer restore() + + // Override vscodeAgentsDirFn to point at temp dir. + restoreDir := overrideVSCodeAgentsDirFn(agentsDir) + defer restoreDir() + + m := newModelWithVSCodeDetected() + m.Screen = ScreenProfileCreate + m.ActiveProfileAdapter = model.AgentVSCodeCopilot + m.ProfileCreateStep = 2 + m.ProfileEditMode = false + m.ProfileDraft = model.Profile{ + Name: "testprofile", + PhaseAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "anthropic", ModelID: "claude-sonnet-4-20250514"}, + }, + } + m.Cursor = 0 // "Create" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + // Must stay on ScreenProfiles, not ScreenSync + if state.Screen != ScreenProfiles { + t.Errorf("screen = %v, want ScreenProfiles (no sync for VS Code)", state.Screen) + } + + // 10 files must exist in agentsDir + entries, err := os.ReadDir(agentsDir) + if err != nil { + t.Fatalf("ReadDir(%q) error = %v", agentsDir, err) + } + if len(entries) != 10 { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("expected 10 agent files, got %d: %v", len(entries), names) + } + + // Profile list should be refreshed + if len(state.VSCodeProfileList) == 0 { + t.Error("VSCodeProfileList should be refreshed after create, but is empty") + } +} + +// TestVSCodeProfileDelete_RemovesFiles_NoSync verifies that delete removes agent files +// and does NOT trigger a sync operation. +func TestVSCodeProfileDelete_RemovesFiles_NoSync(t *testing.T) { + agentsDir := t.TempDir() + + // Write 10 sdd-*-cheap.agent.md files + sddPhases := []string{ + "sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", + "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard", + } + for _, phase := range sddPhases { + fname := phase + "-cheap.agent.md" + if err := os.WriteFile(filepath.Join(agentsDir, fname), []byte("content"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", fname, err) + } + } + + restoreDir := overrideVSCodeAgentsDirFn(agentsDir) + defer restoreDir() + restoreRead := overrideReadVSCodeProfilesFn(agentsDir) + defer restoreRead() + + m := newModelWithVSCodeDetected() + m.Screen = ScreenProfileDelete + m.ActiveProfileAdapter = model.AgentVSCodeCopilot + m.ProfileDeleteTarget = "cheap" + m.Cursor = 0 // "Delete" button + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + // Must return to ScreenProfiles, not ScreenSync + if state.Screen != ScreenProfiles { + t.Errorf("screen = %v, want ScreenProfiles (no sync for VS Code delete)", state.Screen) + } + // OperationRunning must NOT be set (no sync launched) + if state.OperationRunning { + t.Error("OperationRunning should be false after VS Code delete (no sync)") + } + // Files must be gone + for _, phase := range sddPhases { + fname := phase + "-cheap.agent.md" + if _, err := os.Stat(filepath.Join(agentsDir, fname)); !os.IsNotExist(err) { + t.Errorf("file %q should have been removed", fname) + } + } +} + +// TestRenderProfiles_AdapterLabel verifies that the profiles screen title reflects +// the active adapter. +func TestRenderProfiles_AdapterLabel(t *testing.T) { + tests := []struct { + name string + adapterLabel string + wantTitle string + }{ + {"opencode adapter", "OpenCode", "OpenCode SDD Profiles"}, + {"vscode adapter", "VS Code", "VS Code SDD Profiles"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + view := screens.RenderProfiles(nil, 0, nil, tt.adapterLabel) + if !strings.Contains(view, tt.wantTitle) { + t.Errorf("RenderProfiles with adapterLabel=%q missing %q in output:\n%s", + tt.adapterLabel, tt.wantTitle, view) + } + }) + } +} + +// TestRenderProfileDelete_VSCodeWording verifies the wording adapts for VS Code. +func TestRenderProfileDelete_VSCodeWording(t *testing.T) { + t.Run("opencode wording", func(t *testing.T) { + view := screens.RenderProfileDelete("myprofile", 0, false) + if !strings.Contains(view, "Delete & Sync") { + t.Errorf("OpenCode delete should show 'Delete & Sync', got:\n%s", view) + } + }) + + t.Run("vscode wording", func(t *testing.T) { + view := screens.RenderProfileDelete("myprofile", 0, true) + if !strings.Contains(view, "10 agent files") { + t.Errorf("VS Code delete should mention '10 agent files', got:\n%s", view) + } + if strings.Contains(view, "Delete & Sync") { + t.Errorf("VS Code delete should NOT show 'Delete & Sync', got:\n%s", view) + } + }) +} + +// TestWelcomeMenuBothDetected verifies both profile entries appear when both adapters detected. +func TestWelcomeMenuBothDetected(t *testing.T) { + m := newModelWithBothDetected() + m.ProfileList = []model.Profile{{Name: "oc-profile"}} + m.VSCodeProfileList = []model.Profile{{Name: "vsc-profile"}} + + view := m.View() + + if !strings.Contains(view, "OpenCode SDD Profiles (1)") { + t.Errorf("welcome view missing 'OpenCode SDD Profiles (1)', got:\n%s", view) + } + if !strings.Contains(view, "VS Code SDD Profiles (1)") { + t.Errorf("welcome view missing 'VS Code SDD Profiles (1)', got:\n%s", view) + } +} + +// --- helpers for test injection --- + +func overrideReadVSCodeProfilesFn(agentsDir string) func() { + original := readVSCodeProfilesFn + readVSCodeProfilesFn = func(dir string) ([]model.Profile, error) { + // count sdd-*-{name}.agent.md files and return profile names + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + seen := make(map[string]struct{}) + phases := []string{ + "sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", + "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard", + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".agent.md") || !strings.HasPrefix(name, "sdd-") { + continue + } + base := name[:len(name)-len(".agent.md")] + for _, phase := range phases { + prefix := phase + "-" + if strings.HasPrefix(base, prefix) { + profileName := base[len(prefix):] + if profileName != "" { + seen[profileName] = struct{}{} + } + } + } + } + result := make([]model.Profile, 0, len(seen)) + for n := range seen { + result = append(result, model.Profile{Name: n}) + } + return result, nil + } + return func() { readVSCodeProfilesFn = original } +} + +func overrideVSCodeAgentsDirFn(dir string) func() { + original := vscodeAgentsDirFn + vscodeAgentsDirFn = func() string { return dir } + return func() { vscodeAgentsDirFn = original } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index e0a18d175..c5917368d 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1040,12 +1040,12 @@ func TestWelcomeMenu_UninstallNavigation_WithProfiles(t *testing.T) { func TestWelcomeMenu_OptionCount(t *testing.T) { m := NewModel(system.DetectionResult{}, "dev") // Without OpenCode detected: 10 options (includes dedicated OpenCode community plugins and managed uninstall). - opts := screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, false, 0, true) + opts := screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, false, 0, true, false, 0) if len(opts) != 10 { t.Fatalf("WelcomeOptions(showProfiles=false) len = %d, want 10; got %v", len(opts), opts) } // With OpenCode detected: 11 options (adds "OpenCode SDD Profiles"). - optsWithProfiles := screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, true, 0, true) + optsWithProfiles := screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, true, 0, true, false, 0) if len(optsWithProfiles) != 11 { t.Fatalf("WelcomeOptions(showProfiles=true) len = %d, want 11; got %v", len(optsWithProfiles), optsWithProfiles) } diff --git a/internal/tui/screens/profile_delete.go b/internal/tui/screens/profile_delete.go index 46ef0893e..ef7959ac7 100644 --- a/internal/tui/screens/profile_delete.go +++ b/internal/tui/screens/profile_delete.go @@ -9,9 +9,9 @@ import ( ) // RenderProfileDelete renders the profile delete confirmation screen. -// It shows the profile name, the 11 agent keys that will be removed, and -// "Delete & Sync" / "Cancel" options. -func RenderProfileDelete(profileName string, cursor int) string { +// When isVSCode is false, shows OpenCode wording (11 agent keys, "Delete & Sync"). +// When isVSCode is true, shows VS Code wording (10 agent files, "Delete"). +func RenderProfileDelete(profileName string, cursor int, isVSCode bool) string { var b strings.Builder b.WriteString(styles.TitleStyle.Render("Delete Profile")) @@ -20,24 +20,45 @@ func RenderProfileDelete(profileName string, cursor int) string { b.WriteString(styles.WarningStyle.Render(fmt.Sprintf("Are you sure you want to delete profile %q?", profileName))) b.WriteString("\n\n") - b.WriteString(styles.SubtextStyle.Render("The following 11 agent keys will be removed from opencode.json:")) - b.WriteString("\n\n") + if isVSCode { + b.WriteString(styles.SubtextStyle.Render("The following 10 agent files will be removed from ~/.copilot/agents/:")) + b.WriteString("\n\n") - // Show orchestrator key. - b.WriteString(styles.UnselectedStyle.Render(" • sdd-orchestrator-" + profileName)) - b.WriteString("\n") + vscodePhases := []string{ + "sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", + "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard", + } + for _, phase := range vscodePhases { + b.WriteString(styles.UnselectedStyle.Render(" • " + phase + "-" + profileName + ".agent.md")) + b.WriteString("\n") + } - // Show phase keys using the canonical phase list from the sdd package. - for _, phase := range sdd.ProfilePhaseOrder() { - b.WriteString(styles.UnselectedStyle.Render(" • " + phase + "-" + profileName)) b.WriteString("\n") - } + b.WriteString(styles.WarningStyle.Render("This action cannot be undone.")) + b.WriteString("\n\n") - b.WriteString("\n") - b.WriteString(styles.WarningStyle.Render("This action cannot be undone.")) - b.WriteString("\n\n") + b.WriteString(renderOptions([]string{"Delete", "Cancel"}, cursor)) + } else { + b.WriteString(styles.SubtextStyle.Render("The following 11 agent keys will be removed from opencode.json:")) + b.WriteString("\n\n") + + // Show orchestrator key. + b.WriteString(styles.UnselectedStyle.Render(" • sdd-orchestrator-" + profileName)) + b.WriteString("\n") + + // Show phase keys using the canonical phase list from the sdd package. + for _, phase := range sdd.ProfilePhaseOrder() { + b.WriteString(styles.UnselectedStyle.Render(" • " + phase + "-" + profileName)) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(styles.WarningStyle.Render("This action cannot be undone.")) + b.WriteString("\n\n") + + b.WriteString(renderOptions([]string{"Delete & Sync", "Cancel"}, cursor)) + } - b.WriteString(renderOptions([]string{"Delete & Sync", "Cancel"}, cursor)) b.WriteString("\n") b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: confirm • esc: back")) diff --git a/internal/tui/screens/profile_delete_test.go b/internal/tui/screens/profile_delete_test.go index 76f628c06..46c11c40c 100644 --- a/internal/tui/screens/profile_delete_test.go +++ b/internal/tui/screens/profile_delete_test.go @@ -10,7 +10,7 @@ import ( // ─── RenderProfileDelete ────────────────────────────────────────────────────── func TestRenderProfileDelete_ShowsProfileName(t *testing.T) { - output := screens.RenderProfileDelete("cheap", 0) + output := screens.RenderProfileDelete("cheap", 0, false) if !strings.Contains(output, "cheap") { t.Errorf("expected profile name 'cheap' in output, got:\n%s", output) @@ -18,7 +18,7 @@ func TestRenderProfileDelete_ShowsProfileName(t *testing.T) { } func TestRenderProfileDelete_ShowsTitle(t *testing.T) { - output := screens.RenderProfileDelete("premium", 0) + output := screens.RenderProfileDelete("premium", 0, false) if !strings.Contains(output, "Delete Profile") { t.Errorf("expected title 'Delete Profile' in output, got:\n%s", output) @@ -26,7 +26,7 @@ func TestRenderProfileDelete_ShowsTitle(t *testing.T) { } func TestRenderProfileDelete_ShowsDeleteAndSync(t *testing.T) { - output := screens.RenderProfileDelete("cheap", 0) + output := screens.RenderProfileDelete("cheap", 0, false) if !strings.Contains(output, "Delete & Sync") { t.Errorf("expected 'Delete & Sync' option in output, got:\n%s", output) @@ -34,7 +34,7 @@ func TestRenderProfileDelete_ShowsDeleteAndSync(t *testing.T) { } func TestRenderProfileDelete_ShowsCancel(t *testing.T) { - output := screens.RenderProfileDelete("cheap", 1) + output := screens.RenderProfileDelete("cheap", 1, false) if !strings.Contains(output, "Cancel") { t.Errorf("expected 'Cancel' option in output, got:\n%s", output) @@ -42,7 +42,7 @@ func TestRenderProfileDelete_ShowsCancel(t *testing.T) { } func TestRenderProfileDelete_ShowsAgentKeyCount(t *testing.T) { - output := screens.RenderProfileDelete("cheap", 0) + output := screens.RenderProfileDelete("cheap", 0, false) // Should mention 11 agents that will be removed. if !strings.Contains(output, "11") { diff --git a/internal/tui/screens/profiles.go b/internal/tui/screens/profiles.go index 1ee0da329..a27bc3613 100644 --- a/internal/tui/screens/profiles.go +++ b/internal/tui/screens/profiles.go @@ -8,15 +8,24 @@ import ( "github.com/gentleman-programming/gentle-ai/internal/tui/styles" ) -// RenderProfiles renders the OpenCode SDD Profiles list screen. -// It shows all named profiles with their orchestrator model, plus Create and Back actions. +// RenderProfiles renders the SDD Profiles list screen for the given adapter. +// adapterLabel is "OpenCode" or "VS Code" — it drives the title and subtitle text. // deleteErr is displayed when non-nil (e.g. RemoveProfileAgents returned an error). -func RenderProfiles(profiles []model.Profile, cursor int, deleteErr error) string { +func RenderProfiles(profiles []model.Profile, cursor int, deleteErr error, adapterLabel string) string { var b strings.Builder - b.WriteString(styles.TitleStyle.Render("OpenCode SDD Profiles")) + title := adapterLabel + " SDD Profiles" + b.WriteString(styles.TitleStyle.Render(title)) b.WriteString("\n\n") - b.WriteString(styles.SubtextStyle.Render("Your SDD model profiles for OpenCode. Each profile creates its own orchestrator (visible with Tab).")) + + var subtitle string + switch adapterLabel { + case "VS Code": + subtitle = "Your SDD model profiles for VS Code Copilot. Each profile creates 10 .agent.md files in ~/.copilot/agents/." + default: + subtitle = "Your SDD model profiles for OpenCode. Each profile creates its own orchestrator (visible with Tab)." + } + b.WriteString(styles.SubtextStyle.Render(subtitle)) b.WriteString("\n\n") if deleteErr != nil { diff --git a/internal/tui/screens/profiles_test.go b/internal/tui/screens/profiles_test.go index a2d9925bf..d0a9dde31 100644 --- a/internal/tui/screens/profiles_test.go +++ b/internal/tui/screens/profiles_test.go @@ -25,7 +25,7 @@ func makeProfile(name string, orchProvider, orchModel string) model.Profile { func TestRenderProfiles_TitleIsPresent(t *testing.T) { profiles := []model.Profile{makeProfile("cheap", "anthropic", "claude-haiku-4")} - output := screens.RenderProfiles(profiles, 0, nil) + output := screens.RenderProfiles(profiles, 0, nil, "OpenCode") if !strings.Contains(output, "OpenCode SDD Profiles") { t.Errorf("expected title 'OpenCode SDD Profiles' in output, got:\n%s", output) @@ -37,7 +37,7 @@ func TestRenderProfiles_ShowsProfileNamesWithProviderModel(t *testing.T) { makeProfile("cheap", "anthropic", "claude-haiku-4"), makeProfile("premium", "openai", "gpt-4o"), } - output := screens.RenderProfiles(profiles, 0, nil) + output := screens.RenderProfiles(profiles, 0, nil, "OpenCode") if !strings.Contains(output, "cheap") { t.Errorf("expected 'cheap' profile name in output") @@ -55,7 +55,7 @@ func TestRenderProfiles_ShowsProfileNamesWithProviderModel(t *testing.T) { func TestRenderProfiles_ShowsCreateNewProfile(t *testing.T) { profiles := []model.Profile{} - output := screens.RenderProfiles(profiles, 0, nil) + output := screens.RenderProfiles(profiles, 0, nil, "OpenCode") if !strings.Contains(output, "Create new profile") { t.Errorf("expected 'Create new profile' action in output") @@ -64,7 +64,7 @@ func TestRenderProfiles_ShowsCreateNewProfile(t *testing.T) { func TestRenderProfiles_ShowsBackOption(t *testing.T) { profiles := []model.Profile{} - output := screens.RenderProfiles(profiles, 0, nil) + output := screens.RenderProfiles(profiles, 0, nil, "OpenCode") if !strings.Contains(output, "Back") { t.Errorf("expected 'Back' option in output") @@ -73,7 +73,7 @@ func TestRenderProfiles_ShowsBackOption(t *testing.T) { func TestRenderProfiles_ShowsKeybindingHints(t *testing.T) { profiles := []model.Profile{} - output := screens.RenderProfiles(profiles, 0, nil) + output := screens.RenderProfiles(profiles, 0, nil, "OpenCode") if !strings.Contains(output, "n: new") { t.Errorf("expected 'n: new' keybinding hint in output") @@ -89,7 +89,7 @@ func TestRenderProfiles_ShowsKeybindingHints(t *testing.T) { func TestRenderProfiles_ShowsDeleteErrorWhenNonNil(t *testing.T) { profiles := []model.Profile{makeProfile("cheap", "anthropic", "claude-haiku-4")} err := fmt.Errorf("failed to write opencode.json") - output := screens.RenderProfiles(profiles, 0, err) + output := screens.RenderProfiles(profiles, 0, err, "OpenCode") if !strings.Contains(output, "failed to write opencode.json") { t.Errorf("expected delete error message in output, got:\n%s", output) diff --git a/internal/tui/screens/vscode_model_picker.go b/internal/tui/screens/vscode_model_picker.go new file mode 100644 index 000000000..53b6a501d --- /dev/null +++ b/internal/tui/screens/vscode_model_picker.go @@ -0,0 +1,320 @@ +package screens + +import ( + "fmt" + "strings" + + "github.com/gentleman-programming/gentle-ai/internal/model" + vscodeagent "github.com/gentleman-programming/gentle-ai/internal/agents/vscode" + "github.com/gentleman-programming/gentle-ai/internal/tui/styles" +) + +// VSCodeModelPickerState holds navigation state for the VS Code static model picker. +// It is embedded in the profile-create flow when ActiveProfileAdapter == VS Code. +type VSCodeModelPickerState struct { + // Mode mirrors ModelPickerMode: ModePhaseList shows phase rows, + // ModeModelSelect shows the flat VS Code model list for a chosen phase. + Mode ModelPickerMode + + SelectedPhaseIdx int // which phase row was selected + ModelCursor int + ModelScroll int + + // AllPhasesModel tracks the last "Set all phases" assignment (VS Code display name). + AllPhasesModel string // display name of the model, e.g. "Claude Sonnet 4 (copilot)" +} + +// VSCodeModelRows returns the row labels for the VS Code model picker phase list. +// VS Code profiles have no orchestrator row (phases only). +// Row 0 is "Set all phases", rows 1-10 are the 10 SDD phases. +func VSCodeModelRows() []string { + rows := make([]string, 0, 11) + rows = append(rows, "Set all phases") + rows = append(rows, vscodeagent.SDDPhases()...) + return rows +} + +// VSCodeStaticModelNames returns the display names of all VS Code Copilot models +// in the canonical order from vscModelEntries. +func VSCodeStaticModelNames() []string { + entries := vscodeagent.VSCodeStaticModels() + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.DisplayName + } + return names +} + +// RenderVSCodeModelPicker renders the VS Code model picker for profile create step 1. +// It shows a phase list in ModePhaseList, or a flat model list in ModeModelSelect. +func RenderVSCodeModelPicker( + assignments map[string]model.ModelAssignment, + state VSCodeModelPickerState, + cursor int, + editMode bool, + profileName string, +) string { + switch state.Mode { + case ModeModelSelect: + return renderVSCodeModelSelect(state) + default: + return renderVSCodePhaseList(assignments, state, cursor, editMode, profileName) + } +} + +func renderVSCodePhaseList( + assignments map[string]model.ModelAssignment, + state VSCodeModelPickerState, + cursor int, + editMode bool, + profileName string, +) string { + var b strings.Builder + + header := "Create VS Code SDD Profile" + if editMode { + header = "Edit VS Code SDD Profile" + } + b.WriteString(styles.TitleStyle.Render(header)) + b.WriteString("\n\n") + b.WriteString(styles.HeadingStyle.Render("Assign Models")) + b.WriteString("\n") + b.WriteString(styles.SubtextStyle.Render("Assign Copilot models for profile: " + profileName)) + b.WriteString("\n\n") + + rows := VSCodeModelRows() + phases := vscodeagent.SDDPhases() + + for idx, row := range rows { + focused := idx == cursor + + var label string + if idx == 0 { + // "Set all phases" row + if state.AllPhasesModel != "" { + label = fmt.Sprintf("%-22s (%s)", row, state.AllPhasesModel) + } else { + label = fmt.Sprintf("%-22s (not set)", row) + } + } else { + // Phase row — idx 1 maps to phases[0] + phaseIdx := idx - 1 + if phaseIdx < len(phases) { + phase := phases[phaseIdx] + if assignment, ok := assignments[phase]; ok && assignment.ModelID != "" { + label = fmt.Sprintf("%-22s %s", row, assignment.ModelID) + } else { + label = fmt.Sprintf("%-22s (default)", row) + } + } + } + + if focused { + b.WriteString(styles.SelectedStyle.Render(styles.Cursor+label) + "\n") + } else { + b.WriteString(styles.UnselectedStyle.Render(" "+label) + "\n") + } + } + + b.WriteString("\n") + actionIdx := cursor - len(rows) + b.WriteString(renderOptions([]string{"Continue", "← Back"}, actionIdx)) + b.WriteString("\n") + b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: change model / confirm • esc: back")) + + return styles.FrameStyle.Render(b.String()) +} + +func renderVSCodeModelSelect(state VSCodeModelPickerState) string { + var b strings.Builder + + b.WriteString(styles.TitleStyle.Render("Select Copilot model:")) + b.WriteString("\n\n") + + models := VSCodeStaticModelNames() + + end := state.ModelScroll + maxVisibleItems + if end > len(models) { + end = len(models) + } + + if state.ModelScroll > 0 { + b.WriteString(styles.SubtextStyle.Render(" ↑ more")) + b.WriteString("\n") + } + + for i := state.ModelScroll; i < end; i++ { + label := models[i] + focused := i == state.ModelCursor + if focused { + b.WriteString(styles.SelectedStyle.Render(styles.Cursor+label) + "\n") + } else { + b.WriteString(styles.UnselectedStyle.Render(" "+label) + "\n") + } + } + + if end < len(models) { + b.WriteString(styles.SubtextStyle.Render(" ↓ more")) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: select • esc: back")) + + return b.String() +} + +// HandleVSCodeModelPickerNav handles key navigation for the VS Code static model picker. +// Returns true when handled (caller should skip default nav). +func HandleVSCodeModelPickerNav( + key string, + state *VSCodeModelPickerState, + assignments map[string]model.ModelAssignment, +) (handled bool, updated map[string]model.ModelAssignment) { + if assignments == nil { + assignments = make(map[string]model.ModelAssignment) + } + + if state.Mode != ModeModelSelect { + return false, assignments + } + + models := VSCodeStaticModelNames() + entries := vscodeagent.VSCodeStaticModels() + phases := vscodeagent.SDDPhases() + + switch key { + case "up", "k": + if state.ModelCursor > 0 { + state.ModelCursor-- + if state.ModelCursor < state.ModelScroll { + state.ModelScroll = state.ModelCursor + } + } + return true, assignments + case "down", "j": + if state.ModelCursor < len(models)-1 { + state.ModelCursor++ + if state.ModelCursor >= state.ModelScroll+maxVisibleItems { + state.ModelScroll = state.ModelCursor - maxVisibleItems + 1 + } + } + return true, assignments + case "enter": + entry := entries[state.ModelCursor] + assignment := model.ModelAssignment{ + ProviderID: "copilot", + ModelID: entry.DisplayName, + } + if state.SelectedPhaseIdx == 0 { + // "Set all phases" + for _, phase := range phases { + assignments[phase] = assignment + } + state.AllPhasesModel = entry.DisplayName + } else { + phaseIdx := state.SelectedPhaseIdx - 1 + if phaseIdx < len(phases) { + assignments[phases[phaseIdx]] = assignment + } + } + state.Mode = ModePhaseList + state.ModelCursor = 0 + state.ModelScroll = 0 + return true, assignments + case "esc": + state.Mode = ModePhaseList + state.ModelCursor = 0 + state.ModelScroll = 0 + return true, assignments + } + + return false, assignments +} + +// VSCodeModelPickerOptionCount returns the option count for the VS Code phase list. +// Rows + Continue + Back. +func VSCodeModelPickerOptionCount() int { + return len(VSCodeModelRows()) + 2 +} + +// RenderVSCodeProfileCreate renders the multi-step profile create/edit screen for VS Code. +// Step 0: name input (identical to OpenCode) +// Step 1: VS Code model picker (static, no provider hierarchy) +// Step 2: confirm +func RenderVSCodeProfileCreate( + step int, + draft model.Profile, + nameInput string, + namePos int, + nameErr string, + editMode bool, + assignments map[string]model.ModelAssignment, + picker VSCodeModelPickerState, + cursor int, +) string { + switch step { + case 0: + // Reuse the OpenCode name step renderer — it's adapter-agnostic. + return RenderProfileCreate(step, draft, nameInput, namePos, nameErr, editMode, nil, ModelPickerState{}, cursor) + case 1: + return RenderVSCodeModelPicker(assignments, picker, cursor, editMode, draft.Name) + default: + return renderVSCodeProfileConfirmStep(draft, cursor, editMode) + } +} + +// renderVSCodeProfileConfirmStep renders the VS Code confirm step. +func renderVSCodeProfileConfirmStep(draft model.Profile, cursor int, editMode bool) string { + var b strings.Builder + + header := "Create VS Code SDD Profile" + if editMode { + header = "Edit VS Code SDD Profile" + } + b.WriteString(styles.TitleStyle.Render(header)) + b.WriteString("\n\n") + b.WriteString(styles.HeadingStyle.Render("Profile Summary")) + b.WriteString("\n\n") + + b.WriteString(styles.SubtextStyle.Render("Name: ")) + b.WriteString(styles.SelectedStyle.Render(draft.Name)) + b.WriteString("\n") + + phaseCount := len(draft.PhaseAssignments) + if phaseCount > 0 { + b.WriteString(styles.SubtextStyle.Render("Phase assignments: ")) + b.WriteString(styles.UnselectedStyle.Render(fmt.Sprintf("%d assigned", phaseCount))) + b.WriteString("\n") + } else { + b.WriteString(styles.SubtextStyle.Render("Phase assignments: ")) + b.WriteString(styles.UnselectedStyle.Render("(default Copilot model)")) + b.WriteString("\n") + } + + b.WriteString(styles.SubtextStyle.Render("Files to write: ")) + b.WriteString(styles.UnselectedStyle.Render("10 .agent.md files in ~/.copilot/agents/")) + b.WriteString("\n\n") + + confirmLabel := "Create" + if editMode { + confirmLabel = "Save" + } + b.WriteString(renderOptions([]string{confirmLabel, "Cancel"}, cursor)) + b.WriteString("\n") + b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: confirm • esc: back")) + + return styles.FrameStyle.Render(b.String()) +} + +// VSCodeProfileCreateOptionCount returns the number of selectable options for a given step. +func VSCodeProfileCreateOptionCount(step int) int { + switch step { + case 0: + return 0 // text input + case 1: + return VSCodeModelPickerOptionCount() + default: + return 2 // Create/Save + Cancel + } +} diff --git a/internal/tui/screens/welcome.go b/internal/tui/screens/welcome.go index 007f01027..1e4cdec11 100644 --- a/internal/tui/screens/welcome.go +++ b/internal/tui/screens/welcome.go @@ -10,11 +10,11 @@ import ( // WelcomeOptions returns the welcome menu options. // When showProfiles is true, an "OpenCode SDD Profiles" option is inserted -// between "Configure models" and "Manage backups". -// profileCount is used to show a badge with the current profile count. -// When hasEngines is false, "Create your own Agent" is shown as disabled -// (labelled "(no agents)") to signal that no supported AI engine is installed. -func WelcomeOptions(updateResults []update.UpdateResult, updateCheckDone bool, showProfiles bool, profileCount int, hasEngines bool) []string { +// after the plugins entry. profileCount is used to show a badge. +// When showVSCodeProfiles is true, a "VS Code SDD Profiles" option is inserted +// right after the OpenCode profiles entry. vscodeProfileCount is its badge. +// When hasEngines is false, "Create your own Agent" is shown as disabled. +func WelcomeOptions(updateResults []update.UpdateResult, updateCheckDone bool, showProfiles bool, profileCount int, hasEngines bool, showVSCodeProfiles bool, vscodeProfileCount int) []string { upgradeLabel := "Upgrade tools" if updateCheckDone && update.HasUpdates(updateResults) { upgradeLabel = "Upgrade tools ★" @@ -45,6 +45,14 @@ func WelcomeOptions(updateResults []update.UpdateResult, updateCheckDone bool, s opts = append(opts, profilesLabel) } + if showVSCodeProfiles { + vscLabel := "VS Code SDD Profiles" + if vscodeProfileCount > 0 { + vscLabel = fmt.Sprintf("VS Code SDD Profiles (%d)", vscodeProfileCount) + } + opts = append(opts, vscLabel) + } + opts = append(opts, "Manage backups") opts = append(opts, "Managed uninstall") opts = append(opts, "Quit") @@ -52,7 +60,7 @@ func WelcomeOptions(updateResults []update.UpdateResult, updateCheckDone bool, s return opts } -func RenderWelcome(cursor int, version string, updateBanner string, updateResults []update.UpdateResult, updateCheckDone bool, showProfiles bool, profileCount int, hasEngines bool) string { +func RenderWelcome(cursor int, version string, updateBanner string, updateResults []update.UpdateResult, updateCheckDone bool, showProfiles bool, profileCount int, hasEngines bool, showVSCodeProfiles bool, vscodeProfileCount int) string { var b strings.Builder b.WriteString(styles.RenderLogo()) @@ -68,7 +76,7 @@ func RenderWelcome(cursor int, version string, updateBanner string, updateResult b.WriteString("\n") b.WriteString(styles.HeadingStyle.Render("Menu")) b.WriteString("\n\n") - b.WriteString(renderOptions(WelcomeOptions(updateResults, updateCheckDone, showProfiles, profileCount, hasEngines), cursor)) + b.WriteString(renderOptions(WelcomeOptions(updateResults, updateCheckDone, showProfiles, profileCount, hasEngines, showVSCodeProfiles, vscodeProfileCount), cursor)) b.WriteString("\n") b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: select • q: quit")) diff --git a/internal/tui/screens/welcome_test.go b/internal/tui/screens/welcome_test.go index ab52911f3..d2b657b15 100644 --- a/internal/tui/screens/welcome_test.go +++ b/internal/tui/screens/welcome_test.go @@ -12,7 +12,7 @@ import ( // TestWelcomeOptions_WithoutProfiles verifies that when showProfiles is false, // the "OpenCode SDD Profiles" option is NOT present. func TestWelcomeOptions_WithoutProfiles(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, false, 0, true) + opts := screens.WelcomeOptions(nil, true, false, 0, true, false, 0) if !containsOption(opts, "OpenCode Community Plugins") { t.Fatalf("expected dedicated OpenCode Community Plugins option; got: %v", opts) } @@ -26,7 +26,7 @@ func TestWelcomeOptions_WithoutProfiles(t *testing.T) { // TestWelcomeOptions_WithProfiles_ZeroCount shows "OpenCode SDD Profiles" without a badge. func TestWelcomeOptions_WithProfiles_ZeroCount(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, true, 0, true) + opts := screens.WelcomeOptions(nil, true, true, 0, true, false, 0) found := false for _, opt := range opts { if opt == "OpenCode SDD Profiles" { @@ -43,7 +43,7 @@ func TestWelcomeOptions_WithProfiles_ZeroCount(t *testing.T) { // TestWelcomeOptions_WithProfiles_CountTwo shows "OpenCode SDD Profiles (2)". func TestWelcomeOptions_WithProfiles_CountTwo(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, true, 2, true) + opts := screens.WelcomeOptions(nil, true, true, 2, true, false, 0) found := false for _, opt := range opts { if opt == "OpenCode SDD Profiles (2)" { @@ -57,7 +57,7 @@ func TestWelcomeOptions_WithProfiles_CountTwo(t *testing.T) { // TestWelcomeOptions_WithProfiles_CountOne shows "OpenCode SDD Profiles (1)". func TestWelcomeOptions_WithProfiles_CountOne(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, true, 1, true) + opts := screens.WelcomeOptions(nil, true, true, 1, true, false, 0) found := false for _, opt := range opts { if opt == "OpenCode SDD Profiles (1)" { @@ -72,7 +72,7 @@ func TestWelcomeOptions_WithProfiles_CountOne(t *testing.T) { // TestWelcomeOptions_OptionCount_WithoutProfiles verifies 9 options when showProfiles=false // and hasEngines=true (agent option visible). func TestWelcomeOptions_OptionCount_WithoutProfiles(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, false, 0, true) + opts := screens.WelcomeOptions(nil, true, false, 0, true, false, 0) // Expected: Start installation, Upgrade tools, Sync configs, Upgrade + Sync, // Configure models, Create your own Agent, OpenCode Community Plugins, Manage backups, Managed uninstall, Quit = 10 want := 10 @@ -84,7 +84,7 @@ func TestWelcomeOptions_OptionCount_WithoutProfiles(t *testing.T) { // TestWelcomeOptions_OptionCount_WithProfiles verifies 10 options when showProfiles=true // and hasEngines=true. func TestWelcomeOptions_OptionCount_WithProfiles(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, true, 2, true) + opts := screens.WelcomeOptions(nil, true, true, 2, true, false, 0) // Expected: Start installation, Upgrade tools, Sync configs, Upgrade + Sync, // Configure models, Create your own Agent, OpenCode Community Plugins, OpenCode SDD Profiles (2), Manage backups, Managed uninstall, Quit = 11 want := 11 @@ -96,7 +96,7 @@ func TestWelcomeOptions_OptionCount_WithProfiles(t *testing.T) { // TestWelcomeOptions_NoEngines_ShowsDisabledLabel verifies that when hasEngines=false, // the agent option is labelled "(no agents)" to signal unavailability. func TestWelcomeOptions_NoEngines_ShowsDisabledLabel(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, false, 0, false) + opts := screens.WelcomeOptions(nil, true, false, 0, false, false, 0) found := false for _, opt := range opts { if strings.Contains(opt, "no agents") { @@ -111,7 +111,7 @@ func TestWelcomeOptions_NoEngines_ShowsDisabledLabel(t *testing.T) { // TestWelcomeOptions_ProfilesInsertedBeforeManageBackups verifies the ordering: // profiles option sits between "Create your own Agent" and "Manage backups". func TestWelcomeOptions_ProfilesInsertedBeforeManageBackups(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, true, 1, true) + opts := screens.WelcomeOptions(nil, true, true, 1, true, false, 0) agentIdx := -1 pluginsIdx := -1 @@ -169,7 +169,7 @@ func containsOption(opts []string, want string) bool { } func TestWelcomeOptions_IncludesManagedUninstall(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, false, 0, true) + opts := screens.WelcomeOptions(nil, true, false, 0, true, false, 0) found := false for _, opt := range opts { @@ -188,7 +188,7 @@ func TestWelcomeOptions_IncludesManagedUninstall(t *testing.T) { // TestRenderWelcome_WithoutProfiles verifies no "OpenCode SDD Profiles" in output. func TestRenderWelcome_WithoutProfiles(t *testing.T) { - output := screens.RenderWelcome(0, "1.0.0", "", nil, true, false, 0, true) + output := screens.RenderWelcome(0, "1.0.0", "", nil, true, false, 0, true, false, 0) if strings.Contains(output, "OpenCode SDD Profiles") { snippet := output if len(snippet) > 200 { @@ -200,7 +200,7 @@ func TestRenderWelcome_WithoutProfiles(t *testing.T) { // TestRenderWelcome_WithProfiles_ZeroCount contains "OpenCode SDD Profiles" but no badge. func TestRenderWelcome_WithProfiles_ZeroCount(t *testing.T) { - output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 0, true) + output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 0, true, false, 0) if !strings.Contains(output, "OpenCode SDD Profiles") { t.Errorf("RenderWelcome(showProfiles=true, count=0) missing 'OpenCode SDD Profiles'") } @@ -211,7 +211,7 @@ func TestRenderWelcome_WithProfiles_ZeroCount(t *testing.T) { // TestRenderWelcome_WithProfiles_CountTwo contains "OpenCode SDD Profiles (2)". func TestRenderWelcome_WithProfiles_CountTwo(t *testing.T) { - output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 2, true) + output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 2, true, false, 0) if !strings.Contains(output, "OpenCode SDD Profiles (2)") { t.Errorf("RenderWelcome(showProfiles=true, count=2) missing 'OpenCode SDD Profiles (2)'") } @@ -219,7 +219,7 @@ func TestRenderWelcome_WithProfiles_CountTwo(t *testing.T) { // TestRenderWelcome_WithProfiles_CountOne contains "OpenCode SDD Profiles (1)". func TestRenderWelcome_WithProfiles_CountOne(t *testing.T) { - output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 1, true) + output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 1, true, false, 0) if !strings.Contains(output, "OpenCode SDD Profiles (1)") { t.Errorf("RenderWelcome(showProfiles=true, count=1) missing 'OpenCode SDD Profiles (1)'") } From 88707da6ea866d91397fefa3666608fbf5482133 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 21:45:00 +0200 Subject: [PATCH 3/9] test(agents): cover VS Code SDD double-injection idempotency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two regression tests that explicitly invoke Inject() twice with identical inputs and assert: 1. The second run reports InjectionResult.Changed = false. 2. The file inventory in ~/.copilot/agents/ is unchanged (same set, same modification times) — proving filemerge.WriteFileAtomic's content-equality short-circuit holds across the full VS Code injection path. Coverage: - Default profile path (step 3c only, 10 files). - Named profile path (step 2c writes 10 suffixed files on top of step 3c's 10 defaults — total 20 files). These tests blind the recent fix at inject.go:456 (changed = changed || len(profileFiles) > 0) against future regressions where a contributor might re-introduce the unconditional Changed=true that broke the idempotency contract. --- internal/components/sdd/vscode_inject_test.go | 150 +++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/internal/components/sdd/vscode_inject_test.go b/internal/components/sdd/vscode_inject_test.go index 7ce32a793..31461c0d9 100644 --- a/internal/components/sdd/vscode_inject_test.go +++ b/internal/components/sdd/vscode_inject_test.go @@ -5,10 +5,11 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/gentleman-programming/gentle-ai/internal/agents" - "github.com/gentleman-programming/gentle-ai/internal/model" "github.com/gentleman-programming/gentle-ai/internal/agents/opencode" + "github.com/gentleman-programming/gentle-ai/internal/model" ) func TestInject_VSCodeSubAgents(t *testing.T) { @@ -161,4 +162,151 @@ func TestPostInjectionValidation_VSCode_MissingFileDetected(t *testing.T) { if err != nil { t.Fatalf("Re-inject after removing verify file error = %v", err) } +} + +// TestInject_VSCode_DefaultProfile_IsIdempotent verifies that running Inject +// twice in a row with identical inputs does NOT duplicate or rewrite files. +// The second run must report Changed=false and leave file mtimes untouched — +// proving that filemerge.WriteFileAtomic's content-equality short-circuit +// holds across the full VS Code default-profile path. +func TestInject_VSCode_DefaultProfile_IsIdempotent(t *testing.T) { + vscodeAdapter, err := agents.NewAdapter("vscode-copilot") + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + + home := t.TempDir() + + first, err := Inject(home, vscodeAdapter, model.SDDModeMulti) + if err != nil { + t.Fatalf("first Inject() error = %v", err) + } + if !first.Changed { + t.Fatal("first Inject() should report Changed = true") + } + + agentsDir := vscodeAdapter.SubAgentsDir(home) + firstFiles, err := snapshotAgentFiles(agentsDir) + if err != nil { + t.Fatalf("snapshotAgentFiles after first inject error = %v", err) + } + if len(firstFiles) != 10 { + t.Fatalf("expected 10 default .agent.md files, got %d", len(firstFiles)) + } + + second, err := Inject(home, vscodeAdapter, model.SDDModeMulti) + if err != nil { + t.Fatalf("second Inject() error = %v", err) + } + if second.Changed { + t.Errorf("second Inject() with identical inputs reported Changed=true; want false (not idempotent)") + } + + assertNoFileChurn(t, agentsDir, firstFiles) +} + +// TestInject_VSCode_NamedProfile_IsIdempotent verifies idempotency for the +// step-2c named profile path. Running Inject twice with the same Profile +// must leave the 10 default agents AND the 10 suffixed profile agents +// unchanged on disk. +func TestInject_VSCode_NamedProfile_IsIdempotent(t *testing.T) { + vscodeAdapter, err := agents.NewAdapter("vscode-copilot") + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + + home := t.TempDir() + + opts := InjectOptions{ + Profiles: []model.Profile{ + { + Name: "cheap", + PhaseAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "anthropic", ModelID: "claude-haiku-4-5"}, + "sdd-verify": {ProviderID: "anthropic", ModelID: "claude-sonnet-4"}, + }, + }, + }, + } + + first, err := Inject(home, vscodeAdapter, model.SDDModeMulti, opts) + if err != nil { + t.Fatalf("first Inject() error = %v", err) + } + if !first.Changed { + t.Fatal("first Inject() with named profile should report Changed = true") + } + + agentsDir := vscodeAdapter.SubAgentsDir(home) + firstFiles, err := snapshotAgentFiles(agentsDir) + if err != nil { + t.Fatalf("snapshotAgentFiles after first inject error = %v", err) + } + // Expect 20: 10 default unsuffixed + 10 "*-cheap.agent.md" + if len(firstFiles) != 20 { + t.Fatalf("expected 20 files (10 default + 10 cheap), got %d", len(firstFiles)) + } + + second, err := Inject(home, vscodeAdapter, model.SDDModeMulti, opts) + if err != nil { + t.Fatalf("second Inject() error = %v", err) + } + if second.Changed { + t.Errorf("second Inject() with identical profile reported Changed=true; want false (named-profile path not idempotent)") + } + + assertNoFileChurn(t, agentsDir, firstFiles) +} + +// snapshotAgentFiles returns a map of file name → mod time for all entries +// in agentsDir. Used to detect spurious rewrites between Inject calls. +func snapshotAgentFiles(agentsDir string) (map[string]time.Time, error) { + entries, err := os.ReadDir(agentsDir) + if err != nil { + return nil, err + } + snap := make(map[string]time.Time, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + return nil, err + } + snap[entry.Name()] = info.ModTime() + } + return snap, nil +} + +// assertNoFileChurn checks that agentsDir matches prior exactly: same file +// set, same modification times. Any divergence indicates a non-idempotent +// rewrite. +func assertNoFileChurn(t *testing.T, agentsDir string, prior map[string]time.Time) { + t.Helper() + entries, err := os.ReadDir(agentsDir) + if err != nil { + t.Fatalf("ReadDir(%q) error = %v", agentsDir, err) + } + if len(entries) != len(prior) { + t.Errorf("file count diverged: prior=%d, current=%d", len(prior), len(entries)) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + t.Fatalf("Info(%q) error = %v", entry.Name(), err) + } + priorMod, existed := prior[entry.Name()] + if !existed { + t.Errorf("new file %q appeared after re-inject — not idempotent", entry.Name()) + continue + } + if !info.ModTime().Equal(priorMod) { + t.Errorf("file %q mtime changed: prior=%v, current=%v — atomic writer rewrote unchanged content", + entry.Name(), priorMod, info.ModTime()) + } + } } \ No newline at end of file From cfff68db70418a012402e464623aea711b49f2d3 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 21:50:42 +0200 Subject: [PATCH 4/9] refactor(tui): load VS Code Copilot model catalog from OpenCode cache The VS Code model picker previously listed only a hardcoded set of 9 display names from vscModelEntries. That static list: - missed models GitHub Copilot exposes today (e.g. newer Claude revisions, GPT-5 family, etc.) and would silently fall behind whenever Copilot adds a model; - stored assignments as {ProviderID: "copilot", ModelID: }, which does not match what VSCodeModelID expects when resolving {{VSC_MODEL}} during injection (it matches by ID substring against vscModelEntries, then falls back to ProviderID/ModelID). This change makes VSCodeModelPickerState load its catalog from the same OpenCode models cache (~/.cache/opencode/models.json) that the OpenCode profile picker uses, restricted to the `github-copilot` provider entry. The picker now reflects whatever Copilot currently advertises and the catalog refreshes whenever the user runs `opencode sync`. Changes: - screens.VSCodeModelPickerState gains Models []opencode.Model and ConfigWarning string fields populated at construction time via the new screens.NewVSCodeModelPickerState(cachePath) helper. - HandleVSCodeModelPickerNav stores assignments as {ProviderID: "github-copilot", ModelID: } so inject.go's VSCModelID substring matcher resolves them correctly. - When the cache is missing or lacks a github-copilot entry, the picker shows a "run opencode sync" warning instead of failing. - Model.go entry points (handleProfileNameInput and the edit-mode branch of confirmProfileCreate) initialize the picker via the new helper, with the OpenCode-only branch unchanged. - Drop now-unused exports VSCodeStaticModels and VSCodeModelEntry from internal/agents/vscode/vscode_profiles.go. User-visible outcome: when creating a VS Code SDD profile and assigning per-phase models, the picker shows the full Copilot catalog the user sees in the install wizard, not a static 9-item list. --- internal/agents/vscode/vscode_profiles.go | 17 --- internal/tui/model.go | 20 ++-- internal/tui/screens/vscode_model_picker.go | 117 ++++++++++++++------ 3 files changed, 93 insertions(+), 61 deletions(-) diff --git a/internal/agents/vscode/vscode_profiles.go b/internal/agents/vscode/vscode_profiles.go index ae5ea3522..318a42c50 100644 --- a/internal/agents/vscode/vscode_profiles.go +++ b/internal/agents/vscode/vscode_profiles.go @@ -276,23 +276,6 @@ func extractProfileName(filename string) string { return "" } -// VSCodeStaticModels returns the static list of VS Code Copilot model entries -// as (modelSubstr, displayName) pairs for the TUI model picker. -// The order matches vscModelEntries — most specific entries first. -func VSCodeStaticModels() []VSCodeModelEntry { - result := make([]VSCodeModelEntry, len(vscModelEntries)) - for i, e := range vscModelEntries { - result[i] = VSCodeModelEntry{ModelSubstr: e.substr, DisplayName: e.display} - } - return result -} - -// VSCodeModelEntry is a public representation of one VS Code model option. -type VSCodeModelEntry struct { - ModelSubstr string // model ID substring used for matching - DisplayName string // human-friendly display name shown in the TUI -} - // ReadVSCodeAgentTemplate reads an embedded .agent.md template by phase name. func ReadVSCodeAgentTemplate(phase string) (string, error) { return assets.Read("vscode/agents/" + phase + ".agent.md") diff --git a/internal/tui/model.go b/internal/tui/model.go index b6092f679..2b90948ee 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3406,9 +3406,13 @@ func (m Model) handleProfileNameInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.ProfileNameCollision = false m.ProfileDraft.Name = name m.ProfileCreateStep = 1 - // Initialize model picker for orchestrator step. + // Initialize model picker for step 1. VS Code uses the github-copilot + // catalog from the OpenCode cache; OpenCode uses the full multi-provider + // picker. cachePath := opencode.DefaultCachePath() - if _, err := osStatModelCache(cachePath); err == nil { + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + m.VSCodeModelPicker = screens.NewVSCodeModelPickerState(cachePath) + } else if _, err := osStatModelCache(cachePath); err == nil { m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) } else { m.ModelPicker = screens.ModelPickerState{} @@ -3502,15 +3506,13 @@ func (m Model) confirmProfileCreate() (tea.Model, tea.Cmd) { // Edit mode: step 0 shows read-only name, enter advances to step 1. if m.ProfileEditMode { m.ProfileCreateStep = 1 + cachePath := opencode.DefaultCachePath() if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { - m.VSCodeModelPicker = screens.VSCodeModelPickerState{} + m.VSCodeModelPicker = screens.NewVSCodeModelPickerState(cachePath) + } else if _, err := osStatModelCache(cachePath); err == nil { + m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) } else { - cachePath := opencode.DefaultCachePath() - if _, err := osStatModelCache(cachePath); err == nil { - m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) - } else { - m.ModelPicker = screens.ModelPickerState{} - } + m.ModelPicker = screens.ModelPickerState{} } m.Cursor = 0 } diff --git a/internal/tui/screens/vscode_model_picker.go b/internal/tui/screens/vscode_model_picker.go index 53b6a501d..520433721 100644 --- a/internal/tui/screens/vscode_model_picker.go +++ b/internal/tui/screens/vscode_model_picker.go @@ -4,24 +4,61 @@ import ( "fmt" "strings" - "github.com/gentleman-programming/gentle-ai/internal/model" vscodeagent "github.com/gentleman-programming/gentle-ai/internal/agents/vscode" + "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/opencode" "github.com/gentleman-programming/gentle-ai/internal/tui/styles" ) -// VSCodeModelPickerState holds navigation state for the VS Code static model picker. -// It is embedded in the profile-create flow when ActiveProfileAdapter == VS Code. +// VSCodeModelPickerState holds navigation state for the VS Code model picker. +// The model catalog is loaded dynamically from the OpenCode models cache +// (provider "github-copilot") rather than from a hardcoded list, so the +// picker reflects whatever models GitHub Copilot currently exposes to the user. type VSCodeModelPickerState struct { // Mode mirrors ModelPickerMode: ModePhaseList shows phase rows, - // ModeModelSelect shows the flat VS Code model list for a chosen phase. + // ModeModelSelect shows the flat list of Copilot models for a chosen phase. Mode ModelPickerMode - SelectedPhaseIdx int // which phase row was selected + // Models is the list of github-copilot models loaded from the OpenCode + // cache (filtered to tool-call-capable models, sorted by Name). + Models []opencode.Model + + // ConfigWarning is non-empty when the OpenCode cache could not be loaded + // or when it does not contain a github-copilot provider entry. The picker + // is still shown but with a banner explaining the situation. + ConfigWarning string + + SelectedPhaseIdx int ModelCursor int ModelScroll int - // AllPhasesModel tracks the last "Set all phases" assignment (VS Code display name). - AllPhasesModel string // display name of the model, e.g. "Claude Sonnet 4 (copilot)" + // AllPhasesModel tracks the last "Set all phases" assignment (display name). + AllPhasesModel string +} + +// NewVSCodeModelPickerState loads the github-copilot model catalog from the +// OpenCode models cache and returns a picker state ready for use. When the +// cache is missing or the provider entry is absent, ConfigWarning is populated +// and Models is empty — the UI surfaces this to the user. +func NewVSCodeModelPickerState(cachePath string) VSCodeModelPickerState { + providers, err := opencode.LoadModels(cachePath) + if err != nil { + return VSCodeModelPickerState{ + Mode: ModePhaseList, + ConfigWarning: fmt.Sprintf("Could not load models cache %q: %v. Run `opencode sync` to populate it.", cachePath, err), + } + } + copilot, ok := providers["github-copilot"] + if !ok { + return VSCodeModelPickerState{ + Mode: ModePhaseList, + ConfigWarning: "github-copilot provider not found in OpenCode models cache. Run `opencode sync` to fetch the Copilot model catalog.", + } + } + return VSCodeModelPickerState{ + Mode: ModePhaseList, + Models: opencode.FilterModelsForSDD(copilot), + } } // VSCodeModelRows returns the row labels for the VS Code model picker phase list. @@ -34,15 +71,13 @@ func VSCodeModelRows() []string { return rows } -// VSCodeStaticModelNames returns the display names of all VS Code Copilot models -// in the canonical order from vscModelEntries. -func VSCodeStaticModelNames() []string { - entries := vscodeagent.VSCodeStaticModels() - names := make([]string, len(entries)) - for i, e := range entries { - names[i] = e.DisplayName +// vscodeModelLabel returns the display label for a single opencode.Model entry. +// Prefers Name; falls back to ID when Name is empty. +func vscodeModelLabel(m opencode.Model) string { + if m.Name != "" { + return m.Name } - return names + return m.ID } // RenderVSCodeModelPicker renders the VS Code model picker for profile create step 1. @@ -82,6 +117,11 @@ func renderVSCodePhaseList( b.WriteString(styles.SubtextStyle.Render("Assign Copilot models for profile: " + profileName)) b.WriteString("\n\n") + if state.ConfigWarning != "" { + b.WriteString(styles.WarningStyle.Render(state.ConfigWarning)) + b.WriteString("\n\n") + } + rows := VSCodeModelRows() phases := vscodeagent.SDDPhases() @@ -90,14 +130,12 @@ func renderVSCodePhaseList( var label string if idx == 0 { - // "Set all phases" row if state.AllPhasesModel != "" { label = fmt.Sprintf("%-22s (%s)", row, state.AllPhasesModel) } else { label = fmt.Sprintf("%-22s (not set)", row) } } else { - // Phase row — idx 1 maps to phases[0] phaseIdx := idx - 1 if phaseIdx < len(phases) { phase := phases[phaseIdx] @@ -131,11 +169,20 @@ func renderVSCodeModelSelect(state VSCodeModelPickerState) string { b.WriteString(styles.TitleStyle.Render("Select Copilot model:")) b.WriteString("\n\n") - models := VSCodeStaticModelNames() + if len(state.Models) == 0 { + if state.ConfigWarning != "" { + b.WriteString(styles.WarningStyle.Render(state.ConfigWarning)) + } else { + b.WriteString(styles.SubtextStyle.Render("No Copilot models available.")) + } + b.WriteString("\n\n") + b.WriteString(styles.HelpStyle.Render("esc: back")) + return b.String() + } end := state.ModelScroll + maxVisibleItems - if end > len(models) { - end = len(models) + if end > len(state.Models) { + end = len(state.Models) } if state.ModelScroll > 0 { @@ -144,7 +191,7 @@ func renderVSCodeModelSelect(state VSCodeModelPickerState) string { } for i := state.ModelScroll; i < end; i++ { - label := models[i] + label := vscodeModelLabel(state.Models[i]) focused := i == state.ModelCursor if focused { b.WriteString(styles.SelectedStyle.Render(styles.Cursor+label) + "\n") @@ -153,7 +200,7 @@ func renderVSCodeModelSelect(state VSCodeModelPickerState) string { } } - if end < len(models) { + if end < len(state.Models) { b.WriteString(styles.SubtextStyle.Render(" ↓ more")) b.WriteString("\n") } @@ -164,7 +211,7 @@ func renderVSCodeModelSelect(state VSCodeModelPickerState) string { return b.String() } -// HandleVSCodeModelPickerNav handles key navigation for the VS Code static model picker. +// HandleVSCodeModelPickerNav handles key navigation for the VS Code model picker. // Returns true when handled (caller should skip default nav). func HandleVSCodeModelPickerNav( key string, @@ -179,8 +226,6 @@ func HandleVSCodeModelPickerNav( return false, assignments } - models := VSCodeStaticModelNames() - entries := vscodeagent.VSCodeStaticModels() phases := vscodeagent.SDDPhases() switch key { @@ -193,7 +238,7 @@ func HandleVSCodeModelPickerNav( } return true, assignments case "down", "j": - if state.ModelCursor < len(models)-1 { + if state.ModelCursor < len(state.Models)-1 { state.ModelCursor++ if state.ModelCursor >= state.ModelScroll+maxVisibleItems { state.ModelScroll = state.ModelCursor - maxVisibleItems + 1 @@ -201,17 +246,20 @@ func HandleVSCodeModelPickerNav( } return true, assignments case "enter": - entry := entries[state.ModelCursor] + if len(state.Models) == 0 { + return true, assignments + } + entry := state.Models[state.ModelCursor] assignment := model.ModelAssignment{ - ProviderID: "copilot", - ModelID: entry.DisplayName, + ProviderID: "github-copilot", + ModelID: entry.ID, } + label := vscodeModelLabel(entry) if state.SelectedPhaseIdx == 0 { - // "Set all phases" for _, phase := range phases { assignments[phase] = assignment } - state.AllPhasesModel = entry.DisplayName + state.AllPhasesModel = label } else { phaseIdx := state.SelectedPhaseIdx - 1 if phaseIdx < len(phases) { @@ -240,7 +288,7 @@ func VSCodeModelPickerOptionCount() int { // RenderVSCodeProfileCreate renders the multi-step profile create/edit screen for VS Code. // Step 0: name input (identical to OpenCode) -// Step 1: VS Code model picker (static, no provider hierarchy) +// Step 1: VS Code model picker (Copilot-only catalog from cache) // Step 2: confirm func RenderVSCodeProfileCreate( step int, @@ -255,7 +303,6 @@ func RenderVSCodeProfileCreate( ) string { switch step { case 0: - // Reuse the OpenCode name step renderer — it's adapter-agnostic. return RenderProfileCreate(step, draft, nameInput, namePos, nameErr, editMode, nil, ModelPickerState{}, cursor) case 1: return RenderVSCodeModelPicker(assignments, picker, cursor, editMode, draft.Name) @@ -311,10 +358,10 @@ func renderVSCodeProfileConfirmStep(draft model.Profile, cursor int, editMode bo func VSCodeProfileCreateOptionCount(step int) int { switch step { case 0: - return 0 // text input + return 0 case 1: return VSCodeModelPickerOptionCount() default: - return 2 // Create/Save + Cancel + return 2 } } From b8896775da90bc4c6585234711da7262f4da02ef Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 22:14:45 +0200 Subject: [PATCH 5/9] chore(docs): remove research artifact from PR scope The vscode-sdd-profiles-research.md doc was a working/exploration artifact useful during design but does not belong in the shipped codebase. The architectural decisions it captured are reflected in proposal/spec/design/tasks (kept in engram and locally in openspec/changes/, which is gitignored). --- docs/vscode-sdd-profiles-research.md | 442 --------------------------- 1 file changed, 442 deletions(-) delete mode 100644 docs/vscode-sdd-profiles-research.md diff --git a/docs/vscode-sdd-profiles-research.md b/docs/vscode-sdd-profiles-research.md deleted file mode 100644 index 53c01a056..000000000 --- a/docs/vscode-sdd-profiles-research.md +++ /dev/null @@ -1,442 +0,0 @@ -# VS Code SDD Profiles — Investigación y Análisis de Arquitectura - -> **Fecha**: 2026-05-11 -> **Contexto**: Análisis previo a la implementación de "VS Code SDD Profiles" para gentle-ai. -> **Estado**: Exploración completada — listo para fase de propuesta (`sdd-propose`). - ---- - -## 1. Resumen Ejecutivo - -El objetivo es replicar el comportamiento **Multi-mode SDD** de OpenCode (perfiles con modelos asignados a cada fase) pero para **VS Code Copilot**. La exploración inicial concluyó erróneamente que VS Code Copilot no soportaba modelos por fase. Sin embargo, la **documentación oficial de VS Code Copilot (actualizada a mayo 2026)** demuestra que VS Code tiene una infraestructura de custom agents y subagents con asignación de modelos **nativa**, comparable (y en algunos aspectos superior) a la de OpenCode. - -**Conclusión**: VS Code Copilot ya soporta multi-mode SDD de forma nativa mediante archivos `.agent.md`. No es necesario emular el comportamiento. - ---- - -## 2. Fuente de la Información - -Toda la información de este documento proviene de fuentes oficiales: - -- [Custom agents in VS Code](https://code.visualstudio.com/docs/copilot/customization/custom-agents) — docs oficiales, publicadas 2026-05-06 -- [Subagents in Visual Studio Code](https://code.visualstudio.com/docs/copilot/agents/subagents) — docs oficiales, publicadas 2026-05-06 -- [Visual Studio Code 1.119 Release Notes](https://code.visualstudio.com/updates/v1_119) — release oficial, 2026-05-06 -- [vscode-copilot-chat source](https://github.com/microsoft/vscode-copilot-chat) — Context7 indexado - ---- - -## 3. Evidencia Documental: VS Code Copilot Soporta Multi-Mode - -### 3.1. Custom Agents con Modelo Asignado - -VS Code Copilot permite definir custom agents en archivos `.agent.md` con YAML frontmatter. El campo `model` acepta: - -- Un modelo único: `model: "Claude Sonnet 4"` -- Un array de fallback: `model: ['Claude Opus 4.5', 'GPT-5.2']` - -**Ejemplo oficial**: - -```markdown ---- -name: test-writer -description: "Writes comprehensive unit tests for TypeScript code" -model: sonnet -allowedTools: - - Read - - Grep - - Glob - - Edit - - Write - - Bash ---- - -You are a test-writing specialist... -``` - -### 3.2. Ubicación Nativa para User-Level Agents - -Según la documentación oficial: - -| Scope | Default file location | -|---|---| -| Workspace | `.github/agents/` folder | -| Workspace (Claude format) | `.claude/agents/` folder | -| **User profile** | **`~/.copilot/agents/`** or your user data | - -> Fuente: [Custom agent file locations](https://code.visualstudio.com/docs/copilot/customization/custom-agents#_custom-agent-file-locations) - -**Implicación**: `~/.copilot/agents/` es el directorio nativo documentado para custom agents a nivel usuario. Gentle-ai ya usa `~/.copilot/skills/`, por lo que `~/.copilot/agents/` es consistente con la convención existente. - -### 3.3. Model Selection para Subagents - -La documentación establece una **prioridad de tres niveles** para la selección del modelo de un subagente: - -1. **Explicit model parameter**: el main agent especifica un modelo directamente al invocar `runSubagent` -2. **Agent-configured model**: la propiedad `model` en el frontmatter del `.agent.md` -3. **Main model**: el modelo que ejecuta la conversación padre - -**Ejemplo documentado**: - -``` -Run a subagent with Claude Sonnet 4.6 to research authentication patterns in this codebase. -``` - -> Fuente: [Model selection for subagents](https://code.visualstudio.com/docs/copilot/agents/subagents#_model-selection-for-subagents) - -### 3.4. Handoffs Nativos entre Agentes - -VS Code Copilot soporta **handoffs** en el frontmatter del agente — transiciones guiadas entre agentes con botón sugerido. Cada handoff puede especificar un modelo distinto: - -```yaml ---- -description: Generate an implementation plan -tools: ['search', 'web'] -handoffs: - - label: Start Implementation - agent: implementation - prompt: Now implement the plan outlined above. - send: false - model: GPT-5.2 (copilot) ---- -``` - -> Fuente: [Handoffs](https://code.visualstudio.com/docs/copilot/customization/custom-agents#_handoffs) - -**Nota**: OpenCode **NO** tiene handoffs nativos. Esta es una ventaja de VS Code Copilot. - -### 3.5. Restricción de Subagentes - -El main agent puede restringir qué subagentes puede invocar: - -```yaml ---- -name: TDD -tools: ['agent'] -agents: ['Red', 'Green', 'Refactor'] ---- -``` - -> Fuente: [Restrict which subagents can be used](https://code.visualstudio.com/docs/copilot/agents/subagents#_restrict-which-subagents-can-be-used-experimental) - -Esto es equivalente al `task` permission de OpenCode (`"sdd-apply": "allow"`). - -### 3.6. Agentes Ocultos (Solo Subagentes) - -```yaml ---- -name: internal-helper -user-invocable: false ---- -``` - -Equivalente a `"hidden": true` en `opencode.json`. - -### 3.7. Background Agents con Modelo Ligero - -Las release notes 1.119 (2026-05-06) confirman que VS Code ya usa múltiples modelos en paralelo: - -> "By offloading todo list management to a lightweight background agent, the main model can focus on the actual task while a smaller model keeps progress tracking in sync." - -Esto demuestra que la arquitectura multi-modelo de VS Code ya está en producción. - ---- - -## 4. Comparativa: OpenCode vs VS Code Copilot (Multi-Mode) - -| Feature | OpenCode | VS Code Copilot (oficial) | -|---|---|---| -| **Archivo de config de agentes** | `opencode.json` | `.agent.md` files (YAML frontmatter + Markdown body) | -| **Ubicación user-level** | `~/.config/opencode/` | `~/.copilot/agents/` | -| **Modelo por agente** | `"model": "provider/modelID"` | `model: "Claude Sonnet 4"` o `model: ['Claude Opus', 'GPT-5']` | -| **Subagent invocation con modelo** | ❌ No (hereda del orchestrator) | ✅ **SÍ** — explicit model parameter | -| **Handoffs entre agentes** | ❌ No | ✅ **SÍ** — nativo con `handoffs:` en frontmatter | -| **Tool restrictions** | ✅ Sí | ✅ Sí — `tools: ['read', 'search']` | -| **Agents restriction** | ✅ Sí (via `task` permissions) | ✅ **SÍ** — `agents: ['Planner', 'Implementer']` | -| **Agentes ocultos** | `"hidden": true` | `user-invocable: false` | -| **Fallback de modelos** | ❌ No | ✅ **SÍ** — array de prioridad | -| **Formato Claude compatible** | ❌ No | ✅ **SÍ** — detecta `.claude/agents/*.md` | - ---- - -## 5. Formato Nativo Propuesto para gentle-ai - -### 5.1. Sub-agente por fase (ejemplo: `sdd-apply`) - -Ubicación: `~/.copilot/agents/sdd-apply.agent.md` - -```markdown ---- -name: sdd-apply -description: "Implement code changes from task definitions" -model: "Claude Sonnet 4.6 (copilot)" -tools: ['read', 'write', 'edit', 'bash'] -user-invocable: false -disable-model-invocation: false -agents: [] ---- - -You are the sdd-apply agent. Implement code changes from task definitions... -``` - -### 5.2. Orchestrator (ejemplo: `gentle-orchestrator`) - -Ubicación: `~/.copilot/agents/gentle-orchestrator.agent.md` - -```markdown ---- -name: gentle-orchestrator -description: "SDD Orchestrator — coordinates sub-agents, never does work inline" -model: "Claude Opus 4.5 (copilot)" -tools: ['agent', 'read', 'write', 'edit', 'bash', 'delegate'] -agents: ['sdd-init', 'sdd-explore', 'sdd-propose', 'sdd-spec', - 'sdd-design', 'sdd-tasks', 'sdd-apply', 'sdd-verify', - 'sdd-archive', 'sdd-onboard'] -user-invocable: true ---- - -## Model Assignments - -Read this table at session start and cache it for the session. - -| Phase | Model | Reason | -|-------|-------|--------| -| orchestrator | Claude Opus 4.5 (copilot) | Coordinates, makes decisions | -| sdd-init | Claude Sonnet 4 (copilot) | Bootstrap SDD context | -| sdd-explore | Claude Sonnet 4 (copilot) | Reads code, structural | -| ... | ... | ... | - -## Sub-Agent References - -When delegating, always invoke the correct sub-agent by name: -- `sdd-init` for bootstrapping -- `sdd-explore` for investigation -- `sdd-apply` for implementation -... -``` - -### 5.3. Handoffs (opcional, para flujos guiados) - -El orchestrator puede definir handoffs para guiar al usuario entre fases: - -```yaml -handoffs: - - label: "Start Exploration" - agent: sdd-explore - prompt: "Explore this codebase to understand..." - send: false -``` - ---- - -## 6. Implicaciones para el Diseño de gentle-ai - -### 6.1. Cambios en el Adaptador VS Code - -El adaptador actual (`internal/agents/vscode/adapter.go`) tiene: - -```go -func (a *Adapter) SupportsSubAgents() bool { - return false // ❌ DEBE CAMBIAR A true -} - -func (a *Adapter) SubAgentsDir(_ string) string { - return "" // ❌ DEBE RETORNAR ~/.copilot/agents/ -} -``` - -**Cambios necesarios**: -- `SupportsSubAgents()`: retornar `true` -- `SubAgentsDir(homeDir)`: retornar `filepath.Join(homeDir, ".copilot", "agents")` -- `EmbeddedSubAgentsDir()`: definir path en assets embebidos (ej: `vscode/agents/`) -- Posiblemente agregar `SupportsWorkflows()` o similar si se usan handoffs - -### 6.2. Nuevo Componente: Generador de `.agent.md` - -Se necesita un componente equivalente a `GenerateProfileOverlay` de OpenCode, pero que genere archivos `.agent.md` en lugar de JSON. - -**Responsabilidades**: -- Generar 11 archivos `.agent.md` por perfil (1 orchestrator + 10 fases) -- Inyectar tabla de model assignments en el body del orchestrator -- Asignar `user-invocable: false` a los sub-agentes -- Asignar `model` a cada agente según el perfil -- Manejar handoffs opcionales - -### 6.3. Estrategia de Inyección - -A diferencia de OpenCode (que hace deep-merge en `opencode.json`), VS Code Copilot requiere: - -- Escribir archivos `.agent.md` físicos en `~/.copilot/agents/` -- No hay merge complejo — cada archivo es independiente -- Borrar archivos de perfiles eliminados (cleanup) -- Manejar nombres de archivo con sufijos para perfiles nombrados (ej: `sdd-apply-cheap.agent.md`) - -### 6.4. Desacoplamiento (Golden Rule) - -Siguiendo la Golden Rule del CODEBASE-GUIDE: - -> "agent-specific paths belong in adapters; reusable behavior belongs in components" - -- **Adaptador** (`internal/agents/vscode/`): define `SubAgentsDir()`, `EmbeddedSubAgentsDir()`, capabilities -- **Componente** (`internal/components/sdd/`): generador de `.agent.md` reusable (similar a `profiles.go` para OpenCode) -- **Assets** (`internal/assets/vscode/`): templates de `.agent.md` embebidos - ---- - -## 7. Las 5 Fases de Implementación - -### Fase 1: Comprensión del contexto y la arquitectura ✅ - -**Estado**: COMPLETADA. - -Se investigó: -- Adaptador actual de VS Code (`internal/agents/vscode/adapter.go`) -- Adaptador de OpenCode y su mecanismo de perfiles -- Documentación oficial de VS Code Copilot (custom agents, subagents, handoffs) -- Infraestructura de assets embebidos y componentes SDD - -**Hallazgo clave**: VS Code Copilot soporta multi-mode nativamente via `.agent.md` files. - -### Fase 2: Modificación del adaptador de VS Code - -**Objetivo**: Habilitar `SupportsSubAgents`, definir paths, agregar tests. - -**Archivos a tocar**: -- `internal/agents/vscode/adapter.go` -- `internal/agents/vscode/adapter_test.go` - -### Fase 3: Generación de los archivos `.agent.md` por fase - -**Objetivo**: Crear generador de `.agent.md` y templates embebidos. - -**Archivos a tocar**: -- `internal/components/sdd/vscode_profiles.go` (nuevo — generador) -- `internal/assets/vscode/` (nuevo — templates embebidos) -- `internal/components/sdd/inject.go` (modificar — agregar path VS Code) -- Tests correspondientes - -### Fase 4: Orquestación mediante Handoffs - -**Objetivo**: Definir handoffs en el orchestrator para guiar flujos SDD. - -**Archivos a tocar**: -- Template del orchestrator `.agent.md` -- Configuración de handoffs en el generador de perfiles - -### Fase 5: Revisión y manejo de errores (Fallback) - -**Objetivo**: Tests de integración, validación post-inyección, rollback. - -**Archivos a tocar**: -- `internal/agents/vscode/adapter_test.go` -- `internal/components/sdd/vscode_profiles_test.go` (nuevo) -- `internal/components/sdd/inject_test.go` (modificar) - ---- - -## 8. Decisiones de Arquitectura - -### 8.1. ¿Handoffs o no handoffs? - -**Recomendación**: Implementar handoffs en una v2. Para la v1, mantener el mismo patrón que OpenCode: el orchestrator delega explícitamente a sub-agentes. Los handoffs agregan complejidad y no están en OpenCode. - -### 8.2. ¿Un solo perfil o múltiples perfiles? - -**Recomendación**: Replicar la misma semántica que OpenCode: -- Perfil default (sin sufijo): `gentle-orchestrator.agent.md`, `sdd-apply.agent.md`, etc. -- Perfiles nombrados (con sufijo): `sdd-orchestrator-cheap.agent.md`, `sdd-apply-cheap.agent.md`, etc. -- El TUI de gentle-ai ya tiene flujo de creación de perfiles — reutilizarlo. - -### 8.3. ¿Dónde vive el system prompt del orchestrator? - -**Opción A**: Todo en `gentle-orchestrator.agent.md` (incluyendo model assignments table). -**Opción B**: System prompt en `gentle-ai.instructions.md` + `.agent.md` files en `~/.copilot/agents/`. - -**Recomendación**: Opción A. El archivo `.agent.md` del orchestrator IS the system prompt. No duplicar en `gentle-ai.instructions.md`. Sin embargo, el `gentle-ai.instructions.md` puede seguir existiendo para instrucciones generales de gentle-ai que no son SDD-specific. - -### 8.4. ¿Cómo se detecta que VS Code lee los agentes? - -**Validación**: Después de la inyección, gentle-ai debería verificar que los archivos `.agent.md` existen y tienen contenido válido (similar al post-check de OpenCode que valida `gentle-orchestrator` en `opencode.json`). - ---- - -## 8.1. Principio de Desacoplamiento Obligatorio (Golden Rule) - -> **"Las rutas y configuraciones específicas de un agente pertenecen a los adaptadores."** -> — CODEBASE-GUIDE.md, Golden Rule - -Esta feature debe implementarse con **cero impacto** en cualquier otro agente. Los principios son: - -### 8.1.1. Aditivo, no Modificativo -- Se AGREGA el adaptador VS Code (`SupportsSubAgents: true`, `SubAgentsDir()`) -- Se CREA un nuevo componente (`vscode_profiles.go`) — no se modifica `profiles.go` de OpenCode -- Se CREA un nuevo directorio de assets (`internal/assets/vscode/`) — no se toca `internal/assets/opencode/` -- Se AGREGA un nuevo path en `inject.go` para el caso `AgentVSCodeCopilot` — el flujo de OpenCode permanece intacto - -### 8.1.2. No Tocar Interfaces Existentes -- `agents.Adapter` interface: NO agregar métodos nuevos que obliguen a otros adaptadores a implementar stubs -- `model.Profile`: reutilizar el tipo existente (ya es agnóstico del agente) -- `model.ModelAssignment`: reutilizar el tipo existente - -### 8.1.3. Namespace Aislado -- Todos los archivos generados usan prefijo `gentle-` o `sdd-` (ej: `sdd-apply.agent.md`) -- No sobrescribir agentes existentes del usuario en `~/.copilot/agents/` -- Cleanup al desinstalar: solo borrar archivos que gentle-ai creó (identificables por prefijo) - -### 8.1.4. Tests Aislados -- Tests del adaptador VS Code: solo prueban el adaptador VS Code -- Tests del generador `.agent.md`: solo prueban el generador -- Tests de integración: verificar que OpenCode, Claude, Cursor, etc. NO se ven afectados - -### 8.1.5. Feature Flag Implícito -- Si el usuario NO selecciona VS Code Copilot como agente, el código nuevo nunca se ejecuta -- Si el usuario tiene VS Code instalado pero NO configura perfiles SDD, el comportamiento default es single-mode (igual que hoy) - ---- - -## 9. Riesgos Identificados - -| Riesgo | Probabilidad | Impacto | Mitigación | -|---|---|---|---| -| VS Code Insiders vs Stable: custom agents avanzados pueden requerir Insiders | Media | Alta | Documentar requisito de versión mínima | -| `.agent.md` en `~/.copilot/agents/` no es detectado por VS Code Stable | Baja | Alta | Validar con VS Code 1.119+ antes de release | -| El campo `model` en frontmatter no acepta el mismo formato que OpenCode | Media | Media | Mapear formatos en el generador (provider/model → "Model Name (vendor)") | -| Conflicto con agentes existentes del usuario en `~/.copilot/agents/` | Baja | Baja | Namespace con prefijo `gentle-` o `sdd-` | -| Tamaño del prompt del orchestrator con tabla de model assignments | Baja | Media | La tabla es texto plano, no debería exceder límites | - ---- - -## 10. Archivos Preliminares a Modificar - -### Adaptador VS Code -- `internal/agents/vscode/adapter.go` — habilitar sub-agents, definir paths -- `internal/agents/vscode/adapter_test.go` — tests de capabilities y paths - -### Componente SDD (nuevo o modificado) -- `internal/components/sdd/vscode_profiles.go` — generador de `.agent.md` -- `internal/components/sdd/vscode_profiles_test.go` — tests del generador -- `internal/components/sdd/inject.go` — agregar path de inyección VS Code -- `internal/components/sdd/inject_test.go` — tests de inyección - -### Assets embebidos (nuevo) -- `internal/assets/vscode/sdd-orchestrator.md` — template del orchestrator -- `internal/assets/vscode/sdd-init.md` — template de fase -- `internal/assets/vscode/sdd-explore.md` — template de fase -- ... (10 fases) - -### Modelo -- `internal/model/types.go` — posiblemente agregar `AgentVSCodeCopilot` (ya existe) -- `internal/model/model_assignment.go` — posiblemente agregar conversión de formato de modelo - -### TUI -- `internal/tui/screens/model_config.go` — posiblemente ajustar picker para VS Code -- `internal/tui/screens/profiles.go` — reutilizar flujo existente - ---- - -## 11. Próximo Paso - -Lanzar `sdd-propose` con este contexto para formalizar la propuesta técnica, seguido de `sdd-spec`, `sdd-design`, `sdd-tasks`, `sdd-apply`, `sdd-verify`, y `sdd-archive`. - ---- - -*Documento generado por gentle-ai SDD exploration phase.* From ef0be07f862b426e76a80e50f54627452ee7453a Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 22:19:19 +0200 Subject: [PATCH 6/9] feat(tui): warn before installing SDD multi-mode that visually duplicates agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code Copilot scans both \`.agent.md\` (its native format) and Claude \`.md\` agent files in parallel. When a user installs SDD multi-mode for both VS Code Copilot and Claude Code through the gentle-ai wizard, the 8 sub-agent phases that both adapters ship (sdd-apply, sdd-archive, sdd-design, sdd-explore, sdd-propose, sdd-spec, sdd-tasks, sdd-verify) appear twice in the VS Code Agent customizations panel. This is not a bug — each agent file is correct and functional in its own host — but it looks broken to a user who sees \"sdd-verify\" listed twice and assumes gentle-ai wrote duplicates. The case was traced down in PR #505 manual testing. Changes: - New ScreenSDDDuplicateAgentsWarning with render function that lists exactly the 8 phases that duplicate (sdd-init and sdd-onboard are excluded because the Claude adapter does not ship them as sub-agents). - shouldWarnAboutDuplicateAgents() returns true when SDD is selected AND both AgentVSCodeCopilot and AgentClaudeCode are in the agent set. Extensible: add other Claude-format adapters to the check as they are introduced. - Wired into the ScreenSDDMode handler: when SDDModeMulti is chosen and the warning condition holds, the warning screen is shown before the normal post-SDDMode flow (ModelPicker / StrictTDD / etc.). - Extracted advanceFromSDDModeSelection() helper so both the SDDMode handler (when no warning is required) and the warning's \"Continue anyway\" path can share the same forward navigation logic. - Warning screen offers two options: \"Continue anyway\" resumes the normal flow; \"Back to adapter selection\" returns to ScreenSDDMode so the user can unselect one adapter. Test coverage: - TestShouldWarnAboutDuplicateAgents (7 subtests) covers the detection helper across all relevant adapter combinations. - TestSDDMode_TriggersDuplicateAgentsWarning verifies that selecting multi-mode with the offending combination routes to the warning. - TestSDDMode_NoWarningWhenNotDuplicating verifies that a benign combination skips the warning. - TestSDDDuplicateAgentsWarning_ContinueAdvances and TestSDDDuplicateAgentsWarning_BackReturnsToSDDMode cover both branches of the warning handler. - TestRenderSDDDuplicateAgentsWarning_ListsExpectedPhases pins the rendered phase list to the contract so a future contributor cannot silently drop or add a phase. --- internal/tui/model.go | 119 ++++++---- .../tui/model_sdd_duplicate_warning_test.go | 204 ++++++++++++++++++ internal/tui/router.go | 1 + internal/tui/screens/sdd_duplicate_warning.go | 75 +++++++ 4 files changed, 361 insertions(+), 38 deletions(-) create mode 100644 internal/tui/model_sdd_duplicate_warning_test.go create mode 100644 internal/tui/screens/sdd_duplicate_warning.go diff --git a/internal/tui/model.go b/internal/tui/model.go index 2b90948ee..86c162326 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -207,6 +207,7 @@ const ( ScreenClaudeModelPicker ScreenKiroModelPicker ScreenSDDMode + ScreenSDDDuplicateAgentsWarning ScreenStrictTDD ScreenOpenCodePlugins ScreenOpenCodePluginResult @@ -782,6 +783,8 @@ func (m Model) View() string { return screens.RenderKiroModelPicker(m.KiroModelPicker, m.Cursor) case ScreenSDDMode: return screens.RenderSDDMode(m.Selection.SDDMode, m.Cursor) + case ScreenSDDDuplicateAgentsWarning: + return screens.RenderSDDDuplicateAgentsWarning(m.Cursor) case ScreenStrictTDD: return screens.RenderStrictTDD(m.Selection.StrictTDD, m.Cursor) case ScreenOpenCodePlugins: @@ -1603,46 +1606,14 @@ func (m Model) confirmSelection() (tea.Model, tea.Cmd) { options := screens.SDDModeOptions() if m.Cursor < len(options) { m.Selection.SDDMode = options[m.Cursor] - if m.Selection.SDDMode == model.SDDModeMulti { - cachePath := opencode.DefaultCachePath() - if _, err := osStatModelCache(cachePath); err == nil { - // Cache exists — OpenCode has been run at least once. - // Show the model picker so the user can assign models. - m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) - m.Selection.ModelAssignments = nil - m.setScreen(ScreenModelPicker) - return m, nil - } - // Cache missing — OpenCode hasn't been run yet on this machine. - // Skip the model picker; models will use OpenCode defaults. - // The picker empty-state message explains what to do after install. - m.ModelPicker = screens.ModelPickerState{} - } - // Clear assignments for both single mode and multi-no-cache paths. - m.Selection.ModelAssignments = nil - // Show StrictTDD screen when OpenCode + SDD are selected. - // This is the next step before the dependency tree. - if m.shouldShowSDDModeScreen() { - m.setScreen(ScreenStrictTDD) + // Surface duplicate-agent warning when SDD multi-mode is paired with + // adapter combinations that VS Code Copilot will surface as duplicate + // entries (e.g. VS Code Copilot + Claude Code). + if m.Selection.SDDMode == model.SDDModeMulti && m.shouldWarnAboutDuplicateAgents() { + m.setScreen(ScreenSDDDuplicateAgentsWarning) return m, nil } - if m.Selection.Preset == model.PresetCustom { - // Custom preset: dependency plan was already built before SDD mode. - // Check skill picker before going to review. - if m.shouldShowSkillPickerScreen() { - if len(m.SkillPicker) == 0 { - m.initSkillPicker() - } - m.setScreen(ScreenSkillPicker) - } else { - m.Review = planner.BuildReviewPayload(m.Selection, m.DependencyPlan) - m.setScreen(ScreenReview) - } - } else { - m.buildDependencyPlan() - m.setScreen(ScreenDependencyTree) - } - return m, nil + return m.advanceFromSDDModeSelection() } // Back — in custom preset, return to ClaudeModelPicker if applicable, // otherwise DependencyTree (component selector). @@ -1661,6 +1632,18 @@ func (m Model) confirmSelection() (tea.Model, tea.Cmd) { m.setScreen(ScreenPreset) } } + case ScreenSDDDuplicateAgentsWarning: + options := screens.SDDDuplicateAgentsWarningOptions() + if m.Cursor < len(options) { + if m.Cursor == 0 { + // "Continue anyway" — resume the normal post-SDDMode flow. + return m.advanceFromSDDModeSelection() + } + // "Back to adapter selection" — return to SDDMode so the user + // can reconsider their adapter set. + m.setScreen(ScreenSDDMode) + } + return m, nil case ScreenModelPicker: // When no providers are detected the screen only shows a "Back" option // at cursor 0. Handle that before the normal row logic. @@ -2757,6 +2740,8 @@ func (m Model) optionCount() int { return screens.KiroModelPickerOptionCount(m.KiroModelPicker) case ScreenSDDMode: return len(screens.SDDModeOptions()) + 1 + case ScreenSDDDuplicateAgentsWarning: + return len(screens.SDDDuplicateAgentsWarningOptions()) case ScreenStrictTDD: return len(screens.StrictTDDOptions()) + 1 // Enable + Disable + Back case ScreenOpenCodePlugins: @@ -3272,6 +3257,64 @@ func (m Model) shouldShowSDDModeScreen() bool { hasSelectedComponent(m.Selection.Components, model.ComponentSDD) } +// shouldWarnAboutDuplicateAgents reports whether the active selection will +// surface visually duplicated sub-agent entries in VS Code Copilot's Agent +// customizations panel. VS Code Copilot scans `.agent.md` (its native +// format) and Claude-format `.md` agents in parallel, so when both +// VS Code Copilot and a Claude-format adapter ship SDD sub-agents the user +// sees each phase twice. +// +// Today only Claude Code writes the conflicting `.md` format from the +// gentle-ai installer. Extend this list when other adapters start shipping +// Claude-format agent files. +func (m Model) shouldWarnAboutDuplicateAgents() bool { + if !hasSelectedComponent(m.Selection.Components, model.ComponentSDD) { + return false + } + if !m.Selection.HasAgent(model.AgentVSCodeCopilot) { + return false + } + return m.Selection.HasAgent(model.AgentClaudeCode) +} + +// advanceFromSDDModeSelection executes the navigation that follows after +// the user has chosen an SDDMode value. It is called directly from the +// ScreenSDDMode handler when no warning is required, and from the +// ScreenSDDDuplicateAgentsWarning handler when the user opts to continue +// past the warning. +func (m Model) advanceFromSDDModeSelection() (tea.Model, tea.Cmd) { + if m.Selection.SDDMode == model.SDDModeMulti { + cachePath := opencode.DefaultCachePath() + if _, err := osStatModelCache(cachePath); err == nil { + m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) + m.Selection.ModelAssignments = nil + m.setScreen(ScreenModelPicker) + return m, nil + } + m.ModelPicker = screens.ModelPickerState{} + } + m.Selection.ModelAssignments = nil + if m.shouldShowSDDModeScreen() { + m.setScreen(ScreenStrictTDD) + return m, nil + } + if m.Selection.Preset == model.PresetCustom { + if m.shouldShowSkillPickerScreen() { + if len(m.SkillPicker) == 0 { + m.initSkillPicker() + } + m.setScreen(ScreenSkillPicker) + } else { + m.Review = planner.BuildReviewPayload(m.Selection, m.DependencyPlan) + m.setScreen(ScreenReview) + } + } else { + m.buildDependencyPlan() + m.setScreen(ScreenDependencyTree) + } + return m, nil +} + // shouldShowStrictTDDScreen reports whether the Strict TDD Mode screen should // be shown in the navigation flow. It requires only that the SDD component is // selected — the screen is agent-agnostic. diff --git a/internal/tui/model_sdd_duplicate_warning_test.go b/internal/tui/model_sdd_duplicate_warning_test.go new file mode 100644 index 000000000..6ca9c167f --- /dev/null +++ b/internal/tui/model_sdd_duplicate_warning_test.go @@ -0,0 +1,204 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/system" + "github.com/gentleman-programming/gentle-ai/internal/tui/screens" +) + +// TestShouldWarnAboutDuplicateAgents covers the detection helper that fires +// the warning screen when SDD multi-mode is paired with VS Code Copilot +// AND a Claude-format adapter. +func TestShouldWarnAboutDuplicateAgents(t *testing.T) { + tests := []struct { + name string + agents []model.AgentID + components []model.ComponentID + want bool + }{ + { + name: "vscode + claude + sdd → warn", + agents: []model.AgentID{model.AgentVSCodeCopilot, model.AgentClaudeCode}, + components: []model.ComponentID{model.ComponentSDD}, + want: true, + }, + { + name: "vscode + claude WITHOUT sdd → no warn", + agents: []model.AgentID{model.AgentVSCodeCopilot, model.AgentClaudeCode}, + components: nil, + want: false, + }, + { + name: "vscode alone + sdd → no warn", + agents: []model.AgentID{model.AgentVSCodeCopilot}, + components: []model.ComponentID{model.ComponentSDD}, + want: false, + }, + { + name: "claude alone + sdd → no warn", + agents: []model.AgentID{model.AgentClaudeCode}, + components: []model.ComponentID{model.ComponentSDD}, + want: false, + }, + { + name: "opencode + claude + sdd → no warn (no vscode)", + agents: []model.AgentID{model.AgentOpenCode, model.AgentClaudeCode}, + components: []model.ComponentID{model.ComponentSDD}, + want: false, + }, + { + name: "vscode + opencode + sdd → no warn (no claude)", + agents: []model.AgentID{model.AgentVSCodeCopilot, model.AgentOpenCode}, + components: []model.ComponentID{model.ComponentSDD}, + want: false, + }, + { + name: "no agents at all", + agents: nil, + components: []model.ComponentID{model.ComponentSDD}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.Selection.Agents = tt.agents + m.Selection.Components = tt.components + if got := m.shouldWarnAboutDuplicateAgents(); got != tt.want { + t.Errorf("shouldWarnAboutDuplicateAgents() = %v, want %v (agents=%v, components=%v)", + got, tt.want, tt.agents, tt.components) + } + }) + } +} + +// TestSDDMode_TriggersDuplicateAgentsWarning verifies that selecting SDD +// multi-mode with the VS Code + Claude combination routes to the warning +// screen instead of the normal next screen. +func TestSDDMode_TriggersDuplicateAgentsWarning(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.Selection.Agents = []model.AgentID{model.AgentVSCodeCopilot, model.AgentClaudeCode} + m.Selection.Components = []model.ComponentID{model.ComponentSDD} + m.Screen = ScreenSDDMode + + // SDD mode options: [Single, Multi]. Cursor 1 → Multi. + options := screens.SDDModeOptions() + multiIdx := -1 + for i, opt := range options { + if opt == model.SDDModeMulti { + multiIdx = i + } + } + if multiIdx < 0 { + t.Fatal("SDDModeMulti option not found in SDDModeOptions()") + } + m.Cursor = multiIdx + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + if state.Screen != ScreenSDDDuplicateAgentsWarning { + t.Fatalf("after selecting Multi with vscode+claude, screen = %v, want ScreenSDDDuplicateAgentsWarning", state.Screen) + } + if state.Selection.SDDMode != model.SDDModeMulti { + t.Errorf("SDDMode = %v, want %v", state.Selection.SDDMode, model.SDDModeMulti) + } +} + +// TestSDDMode_NoWarningWhenNotDuplicating verifies that the warning is NOT +// shown when the adapter set does not trigger duplication (e.g. VS Code +// without Claude). +func TestSDDMode_NoWarningWhenNotDuplicating(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.Selection.Agents = []model.AgentID{model.AgentVSCodeCopilot} + m.Selection.Components = []model.ComponentID{model.ComponentSDD} + m.Selection.Preset = model.PresetMinimal + m.Screen = ScreenSDDMode + + options := screens.SDDModeOptions() + multiIdx := -1 + for i, opt := range options { + if opt == model.SDDModeMulti { + multiIdx = i + } + } + m.Cursor = multiIdx + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + if state.Screen == ScreenSDDDuplicateAgentsWarning { + t.Fatalf("warning fired without claude adapter; screen = %v", state.Screen) + } +} + +// TestSDDDuplicateAgentsWarning_ContinueAdvances verifies that pressing +// Enter on "Continue anyway" resumes the normal SDDMode flow. +func TestSDDDuplicateAgentsWarning_ContinueAdvances(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.Selection.Agents = []model.AgentID{model.AgentVSCodeCopilot, model.AgentClaudeCode} + m.Selection.Components = []model.ComponentID{model.ComponentSDD} + m.Selection.SDDMode = model.SDDModeMulti + m.Selection.Preset = model.PresetMinimal + m.Screen = ScreenSDDDuplicateAgentsWarning + m.Cursor = 0 // "Continue anyway" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + if state.Screen == ScreenSDDDuplicateAgentsWarning { + t.Fatal("after 'Continue anyway', screen still ScreenSDDDuplicateAgentsWarning — advance did not fire") + } + if state.Screen == ScreenSDDMode { + t.Fatal("after 'Continue anyway', screen returned to SDDMode — should advance forward") + } +} + +// TestSDDDuplicateAgentsWarning_BackReturnsToSDDMode verifies that the +// "Back" option returns to the SDDMode selection so the user can change +// their mind. +func TestSDDDuplicateAgentsWarning_BackReturnsToSDDMode(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.Selection.Agents = []model.AgentID{model.AgentVSCodeCopilot, model.AgentClaudeCode} + m.Selection.Components = []model.ComponentID{model.ComponentSDD} + m.Selection.SDDMode = model.SDDModeMulti + m.Screen = ScreenSDDDuplicateAgentsWarning + m.Cursor = 1 // "Back to adapter selection" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + if state.Screen != ScreenSDDMode { + t.Fatalf("after 'Back', screen = %v, want ScreenSDDMode", state.Screen) + } +} + +// TestRenderSDDDuplicateAgentsWarning_ListsExpectedPhases verifies that the +// rendered output names the 8 phases that visibly duplicate. Documents the +// list as part of the contract, so a future contributor cannot silently +// drop one without updating the test. +func TestRenderSDDDuplicateAgentsWarning_ListsExpectedPhases(t *testing.T) { + output := screens.RenderSDDDuplicateAgentsWarning(0) + expected := []string{ + "sdd-apply", "sdd-archive", "sdd-design", "sdd-explore", + "sdd-propose", "sdd-spec", "sdd-tasks", "sdd-verify", + } + for _, phase := range expected { + if !strings.Contains(output, phase) { + t.Errorf("warning output missing duplicated phase %q", phase) + } + } + // The two phases that don't duplicate must NOT be in the list. + for _, phase := range []string{"sdd-init", "sdd-onboard"} { + // Allow them only as part of the prose ("you're installing SDD…"), + // not as bullet-list entries. The bullet entries are prefixed with "•". + if strings.Contains(output, "• "+phase) { + t.Errorf("warning output incorrectly lists non-duplicating phase %q as duplicated", phase) + } + } +} diff --git a/internal/tui/router.go b/internal/tui/router.go index 441d7b738..cdaeb443e 100644 --- a/internal/tui/router.go +++ b/internal/tui/router.go @@ -14,6 +14,7 @@ var linearRoutes = map[Screen]Route{ ScreenClaudeModelPicker: {Forward: ScreenDependencyTree, Backward: ScreenPreset}, ScreenKiroModelPicker: {Forward: ScreenDependencyTree, Backward: ScreenPreset}, ScreenSDDMode: {Forward: ScreenStrictTDD, Backward: ScreenPreset}, + ScreenSDDDuplicateAgentsWarning: {Forward: ScreenStrictTDD, Backward: ScreenSDDMode}, ScreenStrictTDD: {Forward: ScreenDependencyTree, Backward: ScreenSDDMode}, ScreenOpenCodePluginResult: {Backward: ScreenWelcome}, ScreenModelPicker: {Forward: ScreenStrictTDD, Backward: ScreenSDDMode}, diff --git a/internal/tui/screens/sdd_duplicate_warning.go b/internal/tui/screens/sdd_duplicate_warning.go new file mode 100644 index 000000000..8fcff969e --- /dev/null +++ b/internal/tui/screens/sdd_duplicate_warning.go @@ -0,0 +1,75 @@ +package screens + +import ( + "strings" + + "github.com/gentleman-programming/gentle-ai/internal/tui/styles" +) + +// DuplicatedSDDPhases lists the SDD phases that appear in both the Copilot +// native (.agent.md) and the Claude (.md) agent registries. VS Code Copilot +// scans both formats, so when a user installs SDD multi-mode for both +// adapters these phases visually duplicate in the Agent customizations panel. +// +// sdd-init and sdd-onboard are intentionally omitted: the Claude adapter +// does not ship them as sub-agents (they are orchestrator-driven flows), so +// they never duplicate. Keep this list aligned with the Claude adapter's +// embedded agents directory. +func DuplicatedSDDPhases() []string { + return []string{ + "sdd-apply", + "sdd-archive", + "sdd-design", + "sdd-explore", + "sdd-propose", + "sdd-spec", + "sdd-tasks", + "sdd-verify", + } +} + +// SDDDuplicateAgentsWarningOptions returns the selectable options for the +// warning screen, in cursor order. +func SDDDuplicateAgentsWarningOptions() []string { + return []string{"Continue anyway", "← Back to adapter selection"} +} + +// RenderSDDDuplicateAgentsWarning renders an informational warning that +// fires when SDD multi-mode is paired with both VS Code Copilot and a +// Claude-format adapter. The user can continue (accept the duplication) +// or go back to the adapter selection. +func RenderSDDDuplicateAgentsWarning(cursor int) string { + var b strings.Builder + + b.WriteString(styles.TitleStyle.Render("Heads up: VS Code will show duplicated SDD agents")) + b.WriteString("\n\n") + + b.WriteString(styles.SubtextStyle.Render("You're installing SDD multi-mode for both VS Code Copilot and Claude Code.")) + b.WriteString("\n") + b.WriteString(styles.SubtextStyle.Render("VS Code Copilot's agent panel reads two formats in parallel:")) + b.WriteString("\n") + b.WriteString(styles.UnselectedStyle.Render(" • Copilot native: ~/.copilot/agents/*.agent.md")) + b.WriteString("\n") + b.WriteString(styles.UnselectedStyle.Render(" • Claude format: ~/.claude/agents/*.md")) + b.WriteString("\n\n") + + b.WriteString(styles.HeadingStyle.Render("These 8 sub-agents will appear twice in VS Code:")) + b.WriteString("\n") + phases := DuplicatedSDDPhases() + for _, phase := range phases { + b.WriteString(styles.UnselectedStyle.Render(" • " + phase)) + b.WriteString("\n") + } + b.WriteString("\n") + + b.WriteString(styles.SubtextStyle.Render("Each file is correct and works in its own host — no behavior difference.")) + b.WriteString("\n") + b.WriteString(styles.SubtextStyle.Render("This is purely a UI quirk of VS Code's multi-format agent scanner.")) + b.WriteString("\n\n") + + b.WriteString(renderOptions(SDDDuplicateAgentsWarningOptions(), cursor)) + b.WriteString("\n") + b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: select • esc: back")) + + return styles.FrameStyle.Render(b.String()) +} From 35c3378c3b3123e08cac772315cd4565edf0bc26 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 22:39:57 +0200 Subject: [PATCH 7/9] feat(agents): add VS Code Copilot SDD orchestrator agent + PRD doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without an orchestrator template, SDD multi-mode on VS Code Copilot relies on the default chat agent inferring the phase sequence from sub-agent description fields alone. Weak Copilot models routinely skip phases or reorder them, breaking the SDD contract. This change adds an explicit orchestrator template that mirrors the pattern OpenCode, Claude Code, and Kiro already use: - New internal/assets/vscode/agents/sdd-orchestrator.agent.md embedded template with tools: ['agent'], an agents: whitelist of the 10 phases, user-invocable: true, and a body that documents the strict explore → propose → spec → design → tasks → apply → verify → archive sequence plus the init / onboard utility flows. - {{VSC_PROFILE_SUFFIX}} placeholder so the same template serves both the default (unsuffixed) install and named profiles where every phase reference is suffixed with -{name}. - vscode.OrchestratorPhase constant + generateOrchestratorAgent() helper that renders the orchestrator inline for named profiles (separate from the embedded template path used by 3c). - GenerateVSCodeProfileFiles now writes 11 files per profile (orchestrator + 10 phases). RemoveVSCodeProfileAgents already matched the suffix pattern, so it cleans up the orchestrator automatically. - inject.go step 3c resolves {{VSC_PROFILE_SUFFIX}} to empty for the default set; post-check conditionally validates the orchestrator file only when the adapter ships one (VS Code does, Claude Code does not — Claude uses CLAUDE.md as the root orchestrator prompt instead of a separate agent file). Tests: - TestGenerateAgentFile_Orchestrator_DefaultProfile_HasAllRequiredFields pins the YAML contract (tools, agents, user-invocable, etc.). - TestGenerateAgentFile_Orchestrator_NamedProfile_SuffixesAgentNames proves the agents: whitelist AND body references get suffixed in lockstep — without that, the orchestrator would dispatch to nonexistent agents. - TestGenerateAgentFile_Orchestrator_OrchestratorModelAssignment covers three model resolution paths (omit / known / fallback). - TestGenerateVSCodeProfileFiles_IncludesOrchestrator regression guard against the previous 10-file design. - TestRemoveVSCodeProfileAgents_AlsoRemovesOrchestrator regression guard against the orchestrator lingering after delete. - Existing idempotency tests updated for the new counts (11 default, 22 with one named profile). Docs: - docs/prd-vscode-profiles.md captures the full product spec in English, mirroring the format of docs/prd-opencode-profiles.md. Covers problem statement (June 2026 AI Credits transition + enterprise lock-in), vision, scope, detailed requirements, technical design, UX flows, edge cases, and open questions. --- docs/prd-vscode-profiles.md | 572 ++++++++++++++++++ internal/agents/vscode/orchestrator_test.go | 177 ++++++ internal/agents/vscode/vscode_profiles.go | 118 +++- internal/assets/assets_test.go | 1 + .../vscode/agents/sdd-orchestrator.agent.md | 60 ++ internal/components/sdd/inject.go | 22 +- internal/components/sdd/vscode_inject_test.go | 10 +- 7 files changed, 933 insertions(+), 27 deletions(-) create mode 100644 docs/prd-vscode-profiles.md create mode 100644 internal/agents/vscode/orchestrator_test.go create mode 100644 internal/assets/vscode/agents/sdd-orchestrator.agent.md diff --git a/docs/prd-vscode-profiles.md b/docs/prd-vscode-profiles.md new file mode 100644 index 000000000..2ce8ed290 --- /dev/null +++ b/docs/prd-vscode-profiles.md @@ -0,0 +1,572 @@ +# PRD: VS Code Copilot SDD Profiles + +> **Make VS Code Copilot a first-class multi-mode SDD agent — assign different models per SDD phase, switch between named profiles, and let the orchestrator drive the workflow.** + +**Version**: 0.1.0-draft +**Author**: Gentleman Programming +**Date**: 2026-05-11 +**Status**: Draft + +--- + +## 1. Problem Statement + +Today, VS Code Copilot users cannot use Gentle AI's SDD multi-mode workflow on the platform they already pay for. The VS Code adapter reports `SupportsSubAgents() == false`, which means: + +- The wizard's "SDD Mode → Multi" branch is unreachable for Copilot users. +- They cannot assign different models to different SDD phases. +- The whole SDD per-phase cost-optimization story bypasses them. + +Two market shifts make this gap urgent: + +1. **GitHub Copilot is switching to AI Credits in June 2026** — usage-based billing replaces the current Premium Request quota. Every model invocation becomes a real cost, and per-phase model assignment turns from a nice-to-have into a meaningful cost lever (e.g. cheap model for `sdd-spec`, premium for `sdd-apply`). +2. **Most enterprises are standardized on VS Code + Copilot.** Microsoft licensing makes it the path of least resistance. Gentle AI's value proposition stops at the door for them — they'd have to switch to OpenCode, Claude Code, or Kiro to use multi-mode SDD. + +Meanwhile, VS Code Copilot already supports a native multi-agent system: `.agent.md` files in `~/.copilot/agents/`, each one a self-contained sub-agent with YAML frontmatter (`name`, `description`, `model`, `tools`, `agents`, `user-invocable`, `readonly`, `background`) and a Markdown body. This is exactly the primitive Gentle AI needs. + +**This feature closes the gap.** + +--- + +## 2. Vision + +**The user installs Gentle AI with VS Code Copilot selected, picks SDD multi-mode, and optionally creates named profiles ("cheap", "premium", etc.) — each profile gets its own orchestrator + 10 phase executors written to `~/.copilot/agents/` as `.agent.md` files. Inside VS Code Copilot Chat, the user invokes `@sdd-orchestrator` (or `@sdd-orchestrator-cheap`) and the orchestrator dispatches through the SDD phases in deterministic order.** + +``` +~/.copilot/agents/ +├── sdd-orchestrator.agent.md ← default profile orchestrator (user-invocable) +├── sdd-init.agent.md ← default phase executors (10 total) +├── sdd-explore.agent.md +├── sdd-propose.agent.md +├── sdd-spec.agent.md +├── sdd-design.agent.md +├── sdd-tasks.agent.md +├── sdd-apply.agent.md +├── sdd-verify.agent.md +├── sdd-archive.agent.md +├── sdd-onboard.agent.md +│ +├── sdd-orchestrator-cheap.agent.md ← "cheap" profile (uses Haiku for orchestrator) +├── sdd-init-cheap.agent.md +├── ... (10 suffixed executors) +│ +└── sdd-orchestrator-premium.agent.md ← "premium" profile (Opus orchestrator) + ... (10 suffixed executors) +``` + +In Copilot Chat the user types `@sdd-orchestrator-cheap` to drive a budget-friendly SDD run, or `@sdd-orchestrator-premium` for a high-stakes change. + +--- + +## 3. Target Users + +| User | Pain Point | How the Feature Helps | +|------|-----------|-----------------------| +| **Enterprise dev on Copilot** | Locked into VS Code by IT/licensing; cannot reach Gentle AI's SDD value | Native `.agent.md` install — no platform switch required | +| **Cost-conscious solo dev (post-June 2026)** | Every Copilot call costs AI Credits | Per-phase model assignment routes cheap phases to cheap models | +| **Power user with multiple Copilot subscriptions** | Wants to test Sonnet 4 vs Opus 4.5 vs GPT-5 without rewriting agent files | Named profiles encapsulate full model sets; switch with `@orchestrator-{name}` | +| **Team lead** | Wants to standardize SDD profiles across the team | `.agent.md` files are version-controllable in `.github/agents/` or distributed as a config | +| **Onboarding-focused contributor** | Wants the new SDD walkthrough but in VS Code | `@sdd-orchestrator` + `sdd-onboard` sub-agent runs the guided flow | + +--- + +## 4. Scope + +### In Scope (v1 — this PR) + +- VS Code Copilot adapter activates sub-agent support (`SupportsSubAgents() == true`, `SubAgentsDir(homeDir) == ~/.copilot/agents/`). +- 11 embedded `.agent.md` templates under `internal/assets/vscode/agents/`: one orchestrator + 10 phase executors. +- Profile generator (`GenerateVSCodeProfileFiles`) producing 11 files per named profile, with the orchestrator's `agents:` whitelist correctly suffixed. +- Profile remover (`RemoveVSCodeProfileAgents`) cleaning all 11 suffixed files for a named profile; default profile rejected. +- Provider/model → Copilot display name mapping (`vscModelEntries`) covering the 9 most common Copilot models, with a `provider/model` fallback for unknown IDs. +- Injection pipeline (`inject.go` step 2c and 3c) writes default and named-profile files. Post-check verifies `sdd-orchestrator.agent.md` (when shipped), `sdd-apply.agent.md`, and `sdd-verify.agent.md` exist and are non-empty. +- TUI integration (companion PR): welcome menu entry `VS Code SDD Profiles (N)`, adapter-aware profile list / create / delete screens, model picker that pulls the live Copilot catalog from the OpenCode cache (`github-copilot` provider only). +- TUI warning screen surfaced when SDD multi-mode is paired with both VS Code Copilot and Claude Code — Copilot's panel scans both `.agent.md` (native) and `.md` (Claude format), so the 8 overlapping phases would otherwise look duplicated. + +### Out of Scope (permanently) + +- **Profile transport via `opencode.json`.** This feature is exclusive to VS Code Copilot's `.agent.md` format. Profiles for OpenCode live in `opencode.json` (see `prd-opencode-profiles.md`). +- **Custom orchestrator prompt per profile.** All profiles share the same orchestrator instructions; only the model assignment and the suffixed `agents:` whitelist vary between profiles. + +### Out of Scope (v1, future consideration) + +- Export / import VS Code profiles between machines. +- Cross-workspace profile sharing via `.github/agents/`. +- Background / readonly variants of phase executors (e.g. a `sdd-verify-bg` that runs while Apply continues). +- Detection of the deprecated `infer` field on existing user agents in `~/.copilot/agents/`. + +--- + +## 5. Detailed Requirements + +### 5.1 Embedded templates + +**R-VSC-01**: The installer SHALL embed 11 `.agent.md` templates under `internal/assets/vscode/agents/`: + +``` +sdd-orchestrator.agent.md +sdd-init.agent.md +sdd-explore.agent.md +sdd-propose.agent.md +sdd-spec.agent.md +sdd-design.agent.md +sdd-tasks.agent.md +sdd-apply.agent.md +sdd-verify.agent.md +sdd-archive.agent.md +sdd-onboard.agent.md +``` + +**R-VSC-02**: Each template's YAML frontmatter MUST contain at minimum: `name`, `description`, `readonly`, `background`, `user-invocable`. Templates that ship a `model:` field MUST use the sentinel `{{VSC_MODEL}}` so the injector can resolve or remove it. + +**R-VSC-03**: The orchestrator template MUST include `tools: ['agent']` and an `agents:` whitelist enumerating the 10 phases. Without these, VS Code Copilot cannot reliably dispatch sub-agents through it. + +**R-VSC-04**: Phase executors MUST have `user-invocable: false`. Only the orchestrator is `user-invocable: true`. This keeps the agent dropdown focused — users invoke the orchestrator and let it dispatch, rather than scrolling through 11 entries. + +### 5.2 Default install (3c block) + +**R-VSC-10**: When the active adapter is VS Code Copilot and `SupportsSubAgents()` returns true, the injector MUST copy all embedded templates from `internal/assets/vscode/agents/` to `/.copilot/agents/`. + +**R-VSC-11**: The injector MUST resolve `{{VSC_MODEL}}` per template by consulting `opts.OpenCodeModelAssignments` for the phase name (falling back to the `"default"` key). If `VSCodeModelID` returns `""` for the resolved assignment, the entire `model: {{VSC_MODEL}}` line MUST be removed so Copilot falls back to the user's default model. + +**R-VSC-12**: The injector MUST resolve `{{VSC_PROFILE_SUFFIX}}` to the empty string in this path. The orchestrator's `agents:` whitelist therefore references the unsuffixed phase agents. + +**R-VSC-13**: Writes MUST go through `filemerge.WriteFileAtomic` so the operation is idempotent: a re-run with unchanged inputs leaves files untouched and `InjectionResult.Changed` reports `false`. + +### 5.3 Named profile generation (2c block) + +**R-VSC-20**: When the user has defined named profiles (`profile.Name != "" && != "default"`), the injector's 2c block MUST call `vscode.GenerateVSCodeProfileFiles(profile, agentsDir)` for each one. + +**R-VSC-21**: `GenerateVSCodeProfileFiles` MUST produce 11 files per profile, named: + +``` +sdd-orchestrator-{profile}.agent.md +sdd-init-{profile}.agent.md +sdd-explore-{profile}.agent.md +... (8 more) +sdd-onboard-{profile}.agent.md +``` + +**R-VSC-22**: The orchestrator file's `agents:` whitelist MUST be suffixed to match the phase files (e.g. `sdd-apply-cheap`, not `sdd-apply`). Otherwise the orchestrator would dispatch to nonexistent agents. + +**R-VSC-23**: The orchestrator's body references (e.g. "delegate to `sdd-apply`") MUST also be suffixed to keep the dispatch instructions consistent with the whitelist. + +**R-VSC-24**: The orchestrator's `model:` field MUST resolve from `Profile.OrchestratorModel` (not from `PhaseAssignments`). Empty assignment MUST omit the field so Copilot uses the user's default for orchestration. + +**R-VSC-25**: Each phase executor's `model:` field MUST resolve from `Profile.PhaseAssignments[]`. Empty assignment MUST omit the field. + +**R-VSC-26**: Default profile MUST be rejected: `GenerateVSCodeProfileFiles` MUST return an error if `profile.Name == "" || profile.Name == "default"`. The default set is owned by the 3c block. + +### 5.4 Profile removal + +**R-VSC-30**: `RemoveVSCodeProfileAgents(agentsDir, profileName)` MUST delete all 11 suffixed files for the named profile. + +**R-VSC-31**: Default profile MUST NOT be removable: `RemoveVSCodeProfileAgents` MUST return an error for `profileName == "" || "default"`. + +**R-VSC-32**: Missing files MUST be silently skipped (no error). Non-gentle-ai files in `agentsDir` MUST be left untouched — the removal only touches files matching `sdd-*-{profileName}.agent.md` and `sdd-orchestrator-{profileName}.agent.md`. + +### 5.5 Model mapping + +**R-VSC-40**: The injector exposes `VSCodeModelID(assignment ModelAssignment) string` which maps a provider/model pair to a Copilot display name (e.g. `"Claude Sonnet 4 (copilot)"`). + +**R-VSC-41**: The mapping table (`vscModelEntries`) MUST cover the 9 most-used Copilot-exposed models: Claude Sonnet 4, Claude Opus 4.5, Claude Haiku 4.5, Gemini 2.5 Pro, Gemini 2.5 Flash, GPT 4.1, GPT 4.1 Mini, GPT 4o, GPT 4o Mini. + +**R-VSC-42**: Matching uses `strings.Contains` against `ModelID`. Entries MUST be ordered from most-specific to least-specific to avoid partial matches: `gpt-4o-mini` MUST appear before `gpt-4o`; `gpt-4.1-mini` before `gpt-4.1`. The mapping comment MUST document this constraint. + +**R-VSC-43**: Unknown models fall back to `ProviderID + "/" + ModelID` (e.g. `"openai/gpt-5-future"`). Empty `ModelID` returns `""` — the caller MUST omit the `model:` line entirely. + +### 5.6 Post-injection verification + +**R-VSC-50**: After writing the agent files, the injector MUST verify that the critical files exist and are at least 10 bytes: +- `sdd-orchestrator` (only when the adapter ships an orchestrator template — VS Code does, Claude Code does not) +- `sdd-apply` +- `sdd-verify` + +**R-VSC-51**: The verification MUST tolerate the three extensions `.md`, `.yaml`, and `.agent.md` so a single check works across adapters. + +**R-VSC-52**: A truncated or missing critical file MUST cause `Inject()` to return a descriptive error. + +### 5.7 TUI integration (companion PR) + +**R-VSC-60**: The Welcome screen MUST show a `VS Code SDD Profiles (N)` entry when VS Code Copilot is detected. `N` is the count of named profiles currently on disk under `~/.copilot/agents/`. + +**R-VSC-61**: The Profiles screen (existing `ScreenProfiles`) MUST be adapter-aware via `Model.ActiveProfileAdapter` — title and subtitle adapt (`OpenCode` vs `VS Code`), and the underlying detection / write / delete backends route to the right adapter. + +**R-VSC-62**: When creating a VS Code profile, the model picker MUST source its catalog from the OpenCode cache `~/.cache/opencode/models.json`, restricted to the `github-copilot` provider. This guarantees the user only assigns models that Copilot actually supports. + +**R-VSC-63**: VS Code profile create / delete MUST bypass the OpenCode sync pipeline. Writes go directly to disk via `GenerateVSCodeProfileFiles` / `RemoveVSCodeProfileAgents`. After each operation, the TUI MUST refresh the profile list so the badge count stays accurate. + +**R-VSC-64**: When SDD multi-mode is selected together with both VS Code Copilot and a Claude-format adapter (Claude Code in v1), the wizard MUST display a warning screen explaining that VS Code Copilot will show the 8 overlapping sub-agent phases twice. The user can `Continue anyway` or `Back to adapter selection`. + +--- + +## 6. Technical Design + +### 6.1 Data Model + +The `Profile` struct (`internal/model/types.go`) is reused unchanged from the OpenCode feature: + +```go +type Profile struct { + Name string + OrchestratorModel ModelAssignment + PhaseAssignments map[string]ModelAssignment +} +``` + +VS Code does not need a separate type — the same per-phase mapping that drives OpenCode drives VS Code, with the model IDs interpreted through `VSCodeModelID` instead of OpenCode's provider/model format. + +### 6.2 File layout on disk + +``` +~/.copilot/agents/ +├── sdd-orchestrator.agent.md # user-invocable orchestrator, tools: ['agent'] +├── sdd-{phase}.agent.md (×10) # phase executors, user-invocable: false +└── sdd-{phase}-{profile}.agent.md # one full set per named profile (×N profiles) +``` + +Each `.agent.md` is a self-contained unit. There is no shared prompts directory (unlike OpenCode's `~/.config/opencode/prompts/sdd/`) because VS Code Copilot does not support file-reference syntax in agent definitions — the body must be inlined. + +### 6.3 Orchestrator template structure + +```yaml +--- +name: sdd-orchestrator{{VSC_PROFILE_SUFFIX}} +description: > + SDD workflow orchestrator — coordinates the 10 SDD phase executors in a + strict, deterministic sequence. +model: {{VSC_MODEL}} +tools: ['agent'] +agents: + - sdd-init{{VSC_PROFILE_SUFFIX}} + - sdd-explore{{VSC_PROFILE_SUFFIX}} + - ... (8 more) + - sdd-onboard{{VSC_PROFILE_SUFFIX}} +readonly: false +background: false +user-invocable: true +--- + +You are the SDD workflow orchestrator... +1. Delegate to `sdd-explore{{VSC_PROFILE_SUFFIX}}` — Survey the codebase... +2. Delegate to `sdd-propose{{VSC_PROFILE_SUFFIX}}` — ... +... +``` + +For named profiles, `{{VSC_PROFILE_SUFFIX}}` resolves to `-{profile}`. For the default set, it resolves to the empty string. The body's dispatch instructions reference suffixed phase names to match the `agents:` whitelist. + +### 6.4 Phase executor template structure + +```yaml +--- +name: sdd-apply{{VSC_PROFILE_SUFFIX}} +description: > + Implement code changes from task definitions +model: {{VSC_MODEL}} +readonly: false +background: false +user-invocable: false +--- + +You are the SDD **sdd-apply** executor. Do this phase's work yourself. Do NOT delegate further. +You are not the orchestrator. Do NOT call task/delegate. 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`. +``` + +The phase body is short on purpose: detailed work instructions live in `~/.copilot/skills/sdd-{phase}/SKILL.md` (installed separately by the SDD skills component), so updates to the SDD workflow don't require regenerating every agent file. + +### 6.5 Dispatch flow inside VS Code Copilot + +``` +User in Copilot Chat: + @sdd-orchestrator do SDD for "Add export-to-CSV button" + │ + ├─ VS Code routes to sdd-orchestrator.agent.md (user-invocable: true) + │ + ├─ Orchestrator reads its body, which says: + │ "1. Delegate to sdd-explore — Survey the codebase..." + │ + ├─ Orchestrator uses tools: ['agent'] to invoke sdd-explore + │ (sdd-explore is in the agents: whitelist) + │ + ├─ sdd-explore runs as a sub-agent, returns findings + │ + ├─ Orchestrator synthesizes, dispatches to sdd-propose + │ + ├─ ... continues through spec → design → tasks → apply → verify → archive + │ + └─ Each phase reads its SKILL.md and reports back +``` + +Why an explicit orchestrator instead of relying on Copilot Chat's default agent to discover sub-agents from descriptions: + +- **Determinism.** SDD is a strict sequence. Without an orchestrator body listing the phases in order, weaker Copilot models routinely skip phases or reorder them. +- **Restriction.** The `agents:` whitelist lets the orchestrator dispatch only to the 10 SDD phases — Copilot's chat default would also surface any other custom agent the user has installed. +- **Auditability.** The orchestrator body documents the workflow contract; the user can read it to understand what the agent will do before invoking it. + +### 6.6 Affected Files (implementation map) + +| Area | File | Changes | +|------|------|---------| +| **Adapter** | `internal/agents/vscode/adapter.go` | `SupportsSubAgents() = true`; new `SubAgentsDir`, `EmbeddedSubAgentsDir`, `VSCModelID` delegate | +| **Templates** | `internal/assets/vscode/agents/*.agent.md` | NEW — 11 embedded templates | +| **Embed** | `internal/assets/assets.go` | Embed `vscode/` directory tree | +| **Generator** | `internal/agents/vscode/vscode_profiles.go` | NEW — `vscModelEntries`, `VSCodeModelID`, `GenerateAgentFile`, `generateOrchestratorAgent`, `GenerateVSCodeProfileFiles`, `RemoveVSCodeProfileAgents`, `DetectVSCodeProfiles`, `SDDPhases`, `OrchestratorPhase` | +| **Injector** | `internal/components/sdd/inject.go` | New step 2c (named profiles); 3c resolves `{{VSC_MODEL}}` + `{{VSC_PROFILE_SUFFIX}}`; post-check extended to `.agent.md` and conditional orchestrator check | +| **TUI (companion PR)** | `internal/tui/model.go` | `hasDetectedVSCode`, `VSCodeProfileList`, `ActiveProfileAdapter`, adapter-aware handlers, `shouldWarnAboutDuplicateAgents`, `advanceFromSDDModeSelection` | +| **TUI (companion PR)** | `internal/tui/screens/welcome.go` | `VS Code SDD Profiles (N)` menu entry | +| **TUI (companion PR)** | `internal/tui/screens/profiles.go` | `adapterLabel` parameter | +| **TUI (companion PR)** | `internal/tui/screens/profile_delete.go` | `isVSCode` flag + adapter-aware wording | +| **TUI (companion PR)** | `internal/tui/screens/vscode_model_picker.go` | NEW — `VSCodeModelPickerState`, `NewVSCodeModelPickerState`, render functions | +| **TUI (companion PR)** | `internal/tui/screens/sdd_duplicate_warning.go` | NEW — warning screen for VS Code + Claude combo | + +### 6.7 Injection flow + +``` +Inject(homeDir, vscodeAdapter, multiMode, opts) + │ + ├─ Step 2c: VS Code named profiles + │ for each profile in opts.Profiles where Name != "" && Name != "default": + │ vscode.GenerateVSCodeProfileFiles(profile, agentsDir) + │ → 11 files: sdd-orchestrator-{name}.agent.md + 10 sdd-{phase}-{name}.agent.md + │ → writes via filemerge.WriteFileAtomic (idempotent) + │ + ├─ Step 3c: default profile via embedded copy loop + │ for each entry in embedded vscode/agents/: + │ resolve {{VSC_MODEL}} → friendly Copilot name (or remove the line) + │ resolve {{VSC_PROFILE_SUFFIX}} → "" (empty for default) + │ write to / + │ + └─ Post-check + criticalPhases = ["sdd-apply", "sdd-verify"] + if embedded dir contains sdd-orchestrator.{md,yaml,agent.md}: + criticalPhases prepend "sdd-orchestrator" + for each phase: verify /.{md,yaml,agent.md} exists and ≥10 bytes +``` + +### 6.8 Idempotency + +`filemerge.WriteFileAtomic` compares existing file content against the new content before writing. Identical content → no write → `WriteResult.Changed == false`. The 2c block aggregates this across the 11 files via `len(profileFiles) > 0`, and the 3c loop ORs each result into the overall `changed` flag. + +Regression tests `TestInject_VSCode_DefaultProfile_IsIdempotent` and `TestInject_VSCode_NamedProfile_IsIdempotent` lock this contract: invoking `Inject()` twice with identical inputs leaves disk state and `InjectionResult.Changed` unchanged. + +--- + +## 7. UX Flow (companion PR) + +### 7.1 Welcome menu (extended) + +``` +┌─────────────────────────────────────────────────────────┐ +│ ★ Gentleman AI Ecosystem │ +│ │ +│ ▸ Start installation │ +│ Upgrade tools │ +│ Sync configs │ +│ Upgrade + Sync │ +│ Configure models │ +│ Create your own Agent │ +│ OpenCode Community Plugins │ +│ OpenCode SDD Profiles (2) │ +│ VS Code SDD Profiles (1) ← NEW │ +│ Manage backups │ +│ Managed uninstall │ +│ Quit │ +└─────────────────────────────────────────────────────────┘ +``` + +The VS Code entry only appears when VS Code Copilot is detected (`hasDetectedVSCode()` returns true). + +### 7.2 VS Code profile list + +``` +┌─────────────────────────────────────────────────────────┐ +│ VS Code SDD Profiles │ +│ │ +│ Your SDD model profiles for VS Code. Each profile │ +│ creates a dedicated set of per-phase agents. │ +│ │ +│ • cheap │ +│ ▸ premium │ +│ │ +│ Create new profile │ +│ Back │ +│ │ +│ j/k: navigate • enter: edit • n: new • d: delete │ +└─────────────────────────────────────────────────────────┘ +``` + +### 7.3 VS Code model picker (single-provider) + +When the user advances to the model picker for a VS Code profile, the picker is preloaded with the `github-copilot` provider only: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Profile "cheap" — Assign Models │ +│ │ +│ ▸ Set all phases ──── (not set) │ +│ sdd-orchestrator ── claude-opus-4-5 │ +│ sdd-init ────────── claude-haiku-4-5 │ +│ sdd-explore ─────── claude-haiku-4-5 │ +│ ... (8 more) │ +│ Continue │ +│ Back │ +│ │ +│ Provider: github-copilot │ +└─────────────────────────────────────────────────────────┘ +``` + +If the OpenCode model cache is missing or lacks a `github-copilot` entry, the picker renders a banner: + +``` +┌─────────────────────────────────────────────────────────┐ +│ ⚠ github-copilot provider not found in OpenCode │ +│ models cache. Run `opencode sync` first to fetch │ +│ the Copilot model catalog. │ +└─────────────────────────────────────────────────────────┘ +``` + +### 7.4 Duplicate-agents warning (VS Code + Claude combo) + +``` +┌─────────────────────────────────────────────────────────┐ +│ Heads up: VS Code will show duplicated SDD agents │ +│ │ +│ You're installing SDD multi-mode for both VS Code │ +│ Copilot and Claude Code. VS Code Copilot's agent panel │ +│ reads two formats in parallel: │ +│ • Copilot native: ~/.copilot/agents/*.agent.md │ +│ • Claude format: ~/.claude/agents/*.md │ +│ │ +│ These 8 sub-agents will appear twice in VS Code: │ +│ • sdd-apply │ +│ • sdd-archive │ +│ • sdd-design │ +│ • sdd-explore │ +│ • sdd-propose │ +│ • sdd-spec │ +│ • sdd-tasks │ +│ • sdd-verify │ +│ │ +│ Each file is correct and works in its own host — no │ +│ behavior difference. This is purely a UI quirk. │ +│ │ +│ ▸ Continue anyway │ +│ ← Back to adapter selection │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 8. Edge Cases & Decisions + +### 8.1 OpenCode cache missing + +The VS Code model picker reads from `~/.cache/opencode/models.json`, which is populated by running `opencode sync`. If the user has not run OpenCode yet, the cache is absent. The picker SHALL show a banner pointing the user to `opencode sync` and allow them to either (a) cancel and run sync, or (b) proceed without explicit model assignments (Copilot will use the user's default model for every phase). + +### 8.2 VS Code without Copilot subscription + +Some users have VS Code installed but have not paid for Copilot. The `.agent.md` files still get written to `~/.copilot/agents/`, but VS Code does not surface them in the chat. The install succeeds and the post-check passes — the files are there. No special handling is required from the installer side; this is purely a Copilot subscription matter. + +### 8.3 Visual duplication when both VS Code Copilot and Claude Code are installed + +VS Code Copilot scans both `.agent.md` (native) and `.md` (Claude format) directories. When the user installs SDD multi-mode for both adapters, the 8 phases that Claude ships as sub-agents (`sdd-apply`, `sdd-archive`, `sdd-design`, `sdd-explore`, `sdd-propose`, `sdd-spec`, `sdd-tasks`, `sdd-verify`) appear twice in VS Code's Agent customizations panel. `sdd-init` and `sdd-onboard` do not duplicate because Claude does not ship them as sub-agents. + +This is not a bug — each file is correct in its own host. The installer surfaces the wizard warning in §7.4 so the user is not surprised. Two paths to resolve if the user dislikes the duplication: install only one of the two adapters, or accept the duplicates (each works correctly when invoked in its own chat). + +### 8.4 Embedded template extension + +Templates use the `.agent.md` extension on disk and inside the embedded asset filesystem. The injector's post-check tolerates `.md`, `.yaml`, and `.agent.md` so a single helper works across adapters with different conventions. + +### 8.5 Phase ordering inside `vscModelEntries` + +`strings.Contains` is used for matching, so longer substrings MUST come before shorter ones. Examples: + +- `gpt-4o-mini` before `gpt-4o` +- `gpt-4.1-mini` before `gpt-4.1` +- If `claude-sonnet-4-5` is ever added, it MUST go before the existing `claude-sonnet-4` entry + +The mapping table comment documents this and the test `TestVSCodeModelID_KnownProviders` covers each known model. A fallback comment captures the latent risk. + +### 8.6 Default profile cannot be removed + +`RemoveVSCodeProfileAgents("", "default")` is rejected with an error. The default set is owned by the 3c injection block; the only way to remove it is to uninstall the SDD component entirely. + +--- + +## 9. Success Metrics + +| Metric | Target | +|--------|--------| +| Time to create a new VS Code profile (TUI) | < 60 seconds | +| `~/.copilot/agents/` count after a default install | exactly 11 (1 orchestrator + 10 phases) | +| File churn on a re-run with identical config | 0 (idempotent) | +| Profile count supported | Tested up to 5 named profiles | +| Compatibility with `opencode sync` cache schema | 100% (read-only consumer) | +| Behavioral regression for non-VS-Code adapters | 0 (post-check orchestrator branch is conditional) | + +--- + +## 10. Implementation Phases (history) + +These were the apply-time work units. They are listed here for traceability — the feature is already implemented. + +### Phase 1: VS Code adapter capability + +- Adapter reports `SupportsSubAgents() == true`. +- `SubAgentsDir(homeDir)` returns `~/.copilot/agents/`. +- `EmbeddedSubAgentsDir()` returns `"vscode/agents"`. + +### Phase 2: Embedded templates and asset embed + +- Added 11 `.agent.md` templates under `internal/assets/vscode/agents/`. +- Updated `internal/assets/assets.go` to embed `vscode/`. +- Tests cover existence and minimum size of each template. + +### Phase 3: Profile generator + +- `vscode_profiles.go`: `vscModelEntries`, `VSCodeModelID`, `GenerateAgentFile`, `generateOrchestratorAgent`, `SDDPhases`, `OrchestratorPhase`. +- `GenerateVSCodeProfileFiles(profile, agentsDir)` produces 11 files per profile, suffixing names and the orchestrator whitelist. +- `RemoveVSCodeProfileAgents(agentsDir, profileName)` removes all 11 suffixed files. + +### Phase 4: Injection pipeline integration + +- `inject.go` step 2c writes named-profile files via `GenerateVSCodeProfileFiles`. +- 3c resolves `{{VSC_MODEL}}` per phase (via `OpenCodeModelAssignments` lookup) and `{{VSC_PROFILE_SUFFIX}}` (empty for default). +- Post-check extended to recognize `.agent.md` and to conditionally check `sdd-orchestrator`. + +### Phase 5: TUI integration (companion PR) + +- `Model.ActiveProfileAdapter` threads the active adapter through the shared profile screens. +- Welcome menu entry `VS Code SDD Profiles (N)` appears when VS Code Copilot is detected. +- `DetectVSCodeProfiles(agentsDir)` scans `~/.copilot/agents/sdd-*-{name}.agent.md` and dedupes by `{name}`. +- VS Code-specific model picker (`VSCodeModelPickerState`) loads the `github-copilot` provider catalog from the OpenCode cache. +- Duplicate-agents warning screen when SDD + VS Code + Claude are selected together. + +### Phase 6: Idempotency regression tests + +- `TestInject_VSCode_DefaultProfile_IsIdempotent` — re-run leaves 11 default files untouched. +- `TestInject_VSCode_NamedProfile_IsIdempotent` — re-run leaves 22 files (11 default + 11 cheap) untouched. + +--- + +## 11. Open Questions + +1. **Does VS Code Copilot's `agents:` whitelist support wildcards?** + → As of May 2026, no. Each suffixed agent must be enumerated explicitly. The orchestrator template generates the full list at injection time. + +2. **Should we also write a workspace-level `.github/agents/` set?** + → No. The user-level install in `~/.copilot/agents/` is portable across all VS Code workspaces. Workspace-level installs would create the same visual-duplication issue we already warn about for Claude. + +3. **Should `sdd-onboard` and `sdd-init` be `user-invocable: true`?** + → They could be, since they are entry-point flows. For v1 we keep them dispatched-only to keep the agent dropdown minimal. The orchestrator's body explicitly tells users to ask for "init" or "onboard" — Copilot will route through the orchestrator. + +4. **Can the user change the orchestrator prompt without reinstalling?** + → Yes — they can edit `~/.copilot/agents/sdd-orchestrator.agent.md` directly. The installer will overwrite their changes on the next `gentle-ai sync` because `filemerge.WriteFileAtomic` compares against the template content. If we want to preserve user edits, a follow-up could detect a `# user-edited` marker and skip the file. + +5. **What happens when Copilot deprecates a model that's hardcoded in `vscModelEntries`?** + → The mapping returns the friendly display name regardless, but Copilot Chat will fail to find the model when it tries to dispatch. The user must edit the assignment via the TUI to pick a current model. Future work: detect mappings whose target no longer appears in the OpenCode cache and surface a warning during sync. diff --git a/internal/agents/vscode/orchestrator_test.go b/internal/agents/vscode/orchestrator_test.go new file mode 100644 index 000000000..8c38a89e9 --- /dev/null +++ b/internal/agents/vscode/orchestrator_test.go @@ -0,0 +1,177 @@ +package vscode + +import ( + "os" + "strings" + "testing" + + "github.com/gentleman-programming/gentle-ai/internal/model" +) + +// TestGenerateAgentFile_Orchestrator_DefaultProfile_HasAllRequiredFields verifies +// that the orchestrator agent renders with the YAML frontmatter fields VS Code +// Copilot requires to dispatch to sub-agents: tools, agents whitelist, and +// user-invocable. +func TestGenerateAgentFile_Orchestrator_DefaultProfile_HasAllRequiredFields(t *testing.T) { + content := GenerateAgentFile(OrchestratorPhase, model.Profile{}) + + mustContain := []string{ + "name: sdd-orchestrator\n", + "tools: ['agent']\n", + "agents:\n", + "user-invocable: true\n", + "readonly: false\n", + "background: false\n", + } + for _, want := range mustContain { + if !strings.Contains(content, want) { + t.Errorf("orchestrator frontmatter missing %q\n--- content ---\n%s", want, content) + } + } + + // All 10 phases must appear in the agents whitelist, in canonical order, + // unsuffixed for the default profile. + for _, phase := range sddPhases { + entry := " - " + phase + "\n" + if !strings.Contains(content, entry) { + t.Errorf("orchestrator agents whitelist missing %q", entry) + } + } +} + +// TestGenerateAgentFile_Orchestrator_NamedProfile_SuffixesAgentNames verifies +// that a named profile's orchestrator references the suffixed phase agents, +// not the unsuffixed defaults. Without this, the orchestrator would dispatch +// to the wrong agents. +func TestGenerateAgentFile_Orchestrator_NamedProfile_SuffixesAgentNames(t *testing.T) { + profile := model.Profile{Name: "cheap"} + content := GenerateAgentFile(OrchestratorPhase, profile) + + if !strings.Contains(content, "name: sdd-orchestrator-cheap\n") { + t.Errorf("orchestrator name should be suffixed for named profile; content:\n%s", content) + } + + for _, phase := range sddPhases { + suffixed := " - " + phase + "-cheap\n" + if !strings.Contains(content, suffixed) { + t.Errorf("orchestrator agents whitelist missing suffixed entry %q", suffixed) + } + // Unsuffixed entry must NOT be present in a named profile's orchestrator. + unsuffixed := " - " + phase + "\n" + if strings.Contains(content, unsuffixed) { + t.Errorf("named profile orchestrator must not list unsuffixed agent %q", unsuffixed) + } + } + + // Body references must also be suffixed so the dispatch instructions match + // the agents whitelist. + for _, phase := range []string{"sdd-explore", "sdd-apply", "sdd-verify", "sdd-archive"} { + suffixedRef := "`" + phase + "-cheap`" + if !strings.Contains(content, suffixedRef) { + t.Errorf("orchestrator body must reference suffixed phase %q", suffixedRef) + } + } +} + +// TestGenerateAgentFile_Orchestrator_OrchestratorModelAssignment verifies that +// the orchestrator's model field comes from Profile.OrchestratorModel (not from +// PhaseAssignments, which is for the executors). Empty assignment must omit the +// model line so Copilot uses its default. +func TestGenerateAgentFile_Orchestrator_OrchestratorModelAssignment(t *testing.T) { + tests := []struct { + name string + orchModel model.ModelAssignment + wantModelLine string // "" → must omit + }{ + { + name: "no model → omit field", + orchModel: model.ModelAssignment{}, + wantModelLine: "", + }, + { + name: "claude sonnet → mapped display", + orchModel: model.ModelAssignment{ProviderID: "anthropic", ModelID: "claude-sonnet-4-20250514"}, + wantModelLine: "model: \"Claude Sonnet 4 (copilot)\"\n", + }, + { + name: "gpt-5 unknown → provider/model fallback", + orchModel: model.ModelAssignment{ProviderID: "openai", ModelID: "gpt-5-future"}, + wantModelLine: "model: \"openai/gpt-5-future\"\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + profile := model.Profile{Name: "cheap", OrchestratorModel: tt.orchModel} + content := GenerateAgentFile(OrchestratorPhase, profile) + + if tt.wantModelLine == "" { + if strings.Contains(content, "model:") { + t.Errorf("orchestrator should omit model line when no assignment; got:\n%s", content) + } + return + } + if !strings.Contains(content, tt.wantModelLine) { + t.Errorf("orchestrator model line = missing %q; content:\n%s", tt.wantModelLine, content) + } + }) + } +} + +// TestGenerateVSCodeProfileFiles_IncludesOrchestrator verifies that a named +// profile generates 11 files (orchestrator + 10 phase executors), not 10. +// Regression guard against the earlier 10-only design. +func TestGenerateVSCodeProfileFiles_IncludesOrchestrator(t *testing.T) { + agentsDir := t.TempDir() + profile := model.Profile{ + Name: "premium", + OrchestratorModel: model.ModelAssignment{ + ProviderID: "anthropic", ModelID: "claude-opus-4-5", + }, + PhaseAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "anthropic", ModelID: "claude-sonnet-4"}, + }, + } + + files, err := GenerateVSCodeProfileFiles(profile, agentsDir) + if err != nil { + t.Fatalf("GenerateVSCodeProfileFiles() error = %v", err) + } + if got, want := len(files), 11; got != want { + t.Fatalf("GenerateVSCodeProfileFiles() wrote %d files, want %d (orchestrator + 10 phases)", got, want) + } + + // Orchestrator file must exist with the suffix. + orchPath := agentsDir + "/sdd-orchestrator-premium.agent.md" + for _, f := range files { + if strings.HasSuffix(f, "sdd-orchestrator-premium.agent.md") { + orchPath = f + break + } + } + if orchPath == "" { + t.Fatalf("orchestrator file not in returned list; files: %v", files) + } +} + +// TestRemoveVSCodeProfileAgents_AlsoRemovesOrchestrator verifies that removing +// a profile cleans up its orchestrator file alongside the 10 phase files. +// Without this, the orchestrator would linger and silently dispatch to +// nonexistent suffixed agents. +func TestRemoveVSCodeProfileAgents_AlsoRemovesOrchestrator(t *testing.T) { + agentsDir := t.TempDir() + profile := model.Profile{Name: "cheap"} + if _, err := GenerateVSCodeProfileFiles(profile, agentsDir); err != nil { + t.Fatalf("setup: GenerateVSCodeProfileFiles() error = %v", err) + } + + if err := RemoveVSCodeProfileAgents(agentsDir, "cheap"); err != nil { + t.Fatalf("RemoveVSCodeProfileAgents() error = %v", err) + } + + // Orchestrator file must be gone. + orchPath := agentsDir + "/sdd-orchestrator-cheap.agent.md" + if _, err := os.Stat(orchPath); err == nil { + t.Errorf("sdd-orchestrator-cheap.agent.md still exists after removal") + } +} diff --git a/internal/agents/vscode/vscode_profiles.go b/internal/agents/vscode/vscode_profiles.go index fcb894f28..c57c1b29c 100644 --- a/internal/agents/vscode/vscode_profiles.go +++ b/internal/agents/vscode/vscode_profiles.go @@ -83,10 +83,23 @@ var sddPhaseDescriptions = map[string]string{ "sdd-onboard": "Guided end-to-end SDD walkthrough", } -// GenerateAgentFile produces .agent.md content with YAML frontmatter and markdown body -// for a VS Code Copilot sub-agent. The profile name is used to suffix the agent name -// for named profiles (e.g., "sdd-apply-cheap"), and omitted for the default profile. +// OrchestratorPhase is the name of the orchestrator agent that coordinates +// dispatch to the 10 SDD phase executors. It must be in sync with the +// embedded template at internal/assets/vscode/agents/sdd-orchestrator.agent.md +// and with the OpenCode SDDOrchestratorPhase constant. +const OrchestratorPhase = "sdd-orchestrator" + +// GenerateAgentFile produces .agent.md content with YAML frontmatter and markdown +// body for a VS Code Copilot agent. The profile name is used to suffix the agent +// name for named profiles (e.g., "sdd-apply-cheap"), and omitted for the default +// profile. When phase == OrchestratorPhase, the orchestrator template is used +// (with `tools: ['agent']`, an `agents:` whitelist, and `user-invocable: true`). +// All other phases produce phase executor agents with `user-invocable: false`. func GenerateAgentFile(phase string, profile model.Profile) string { + if phase == OrchestratorPhase { + return generateOrchestratorAgent(profile) + } + agentName := phase if profile.Name != "" && profile.Name != "default" { agentName = phase + "-" + profile.Name @@ -97,37 +110,99 @@ func GenerateAgentFile(phase string, profile model.Profile) string { description = "SDD " + phase + " executor" } - // Build YAML frontmatter var sb strings.Builder sb.WriteString("---\n") - sb.WriteString(fmt.Sprintf("name: %s\n", agentName)) - sb.WriteString(fmt.Sprintf("description: >\n %s\n", description)) + fmt.Fprintf(&sb, "name: %s\n", agentName) + fmt.Fprintf(&sb, "description: >\n %s\n", description) - // Model resolution: if the phase has a model assignment, resolve it if assignment, ok := profile.PhaseAssignments[phase]; ok { - modelID := VSCodeModelID(assignment) - if modelID != "" { - sb.WriteString(fmt.Sprintf("model: \"%s\"\n", modelID)) + if modelID := VSCodeModelID(assignment); modelID != "" { + fmt.Fprintf(&sb, "model: \"%s\"\n", modelID) } } - // If no assignment, the model field is omitted — Copilot uses its default sb.WriteString("readonly: false\n") sb.WriteString("background: false\n") - // Phase executors are NOT user-invocable — they are dispatched by the orchestrator sb.WriteString("user-invocable: false\n") sb.WriteString("---\n\n") - // Markdown body — SDD phase executor instructions - sb.WriteString(fmt.Sprintf("You are the SDD **%s** executor. Do this phase's work yourself. Do NOT delegate further.\n", phase)) + fmt.Fprintf(&sb, "You are the SDD **%s** executor. Do this phase's work yourself. Do NOT delegate further.\n", phase) sb.WriteString("You are not the orchestrator. Do NOT call task/delegate. Do NOT launch sub-agents.\n\n") sb.WriteString("## Instructions\n\n") - sb.WriteString(fmt.Sprintf("Read the skill file at `~/.copilot/skills/sdd-%s/SKILL.md` and follow it exactly.\n", phaseWithoutPrefix(phase))) + fmt.Fprintf(&sb, "Read the skill file at `~/.copilot/skills/sdd-%s/SKILL.md` and follow it exactly.\n", phaseWithoutPrefix(phase)) sb.WriteString("Also read shared conventions at `~/.copilot/skills/_shared/sdd-phase-common.md`.\n") return sb.String() } +// generateOrchestratorAgent renders the SDD orchestrator agent for a profile. +// The orchestrator has tools: ['agent'] and an `agents:` whitelist so VS Code +// Copilot's main chat agent can dispatch through it deterministically rather +// than inferring the SDD sequence from sub-agent descriptions alone. +func generateOrchestratorAgent(profile model.Profile) string { + suffix := "" + if profile.Name != "" && profile.Name != "default" { + suffix = "-" + profile.Name + } + + var sb strings.Builder + sb.WriteString("---\n") + fmt.Fprintf(&sb, "name: %s%s\n", OrchestratorPhase, suffix) + sb.WriteString("description: >\n SDD workflow orchestrator — coordinates the 10 SDD phase executors in a strict, deterministic sequence.\n") + + if profile.OrchestratorModel.ModelID != "" { + if modelID := VSCodeModelID(profile.OrchestratorModel); modelID != "" { + fmt.Fprintf(&sb, "model: \"%s\"\n", modelID) + } + } + + sb.WriteString("tools: ['agent']\n") + sb.WriteString("agents:\n") + for _, phase := range sddPhases { + fmt.Fprintf(&sb, " - %s%s\n", phase, suffix) + } + sb.WriteString("readonly: false\n") + sb.WriteString("background: false\n") + sb.WriteString("user-invocable: true\n") + sb.WriteString("---\n\n") + + sb.WriteString("You are the SDD workflow orchestrator for the Gentleman AI ecosystem in VS Code Copilot.\n\n") + sb.WriteString("Your job is to coordinate the SDD phase executors in a strict, deterministic sequence. ") + sb.WriteString("You do NOT perform phase work yourself — you delegate to the matching `sdd-*` sub-agent and synthesize their results back to the user.\n\n") + + sb.WriteString("## SDD phase sequence — substantial changes\n\n") + sb.WriteString("For any non-trivial change, drive the user through this exact sequence. Do NOT skip phases.\n\n") + steps := []struct { + num int + phase string + desc string + }{ + {1, "sdd-explore", "Survey the codebase, gather context, compare approaches. No files written yet."}, + {2, "sdd-propose", "Draft a change proposal with intent, scope, and approach."}, + {3, "sdd-spec", "Write requirements and acceptance scenarios derived from the proposal."}, + {4, "sdd-design", "Document the technical design and file-change plan."}, + {5, "sdd-tasks", "Break the change into an ordered task checklist."}, + {6, "sdd-apply", "Implement the tasks. When Strict TDD is enabled, the executor follows Red-Green-Refactor."}, + {7, "sdd-verify", "Validate the implementation against spec/design/tasks. Reports CRITICAL / WARNING / SUGGESTION findings."}, + {8, "sdd-archive", "Sync delta specs into the main spec set and close the change."}, + } + for _, s := range steps { + fmt.Fprintf(&sb, "%d. Delegate to `%s%s` — %s\n", s.num, s.phase, suffix, s.desc) + } + sb.WriteString("\n## SDD utility flows\n\n") + fmt.Fprintf(&sb, "- Delegate to `sdd-init%s` when the project has not yet been initialized for SDD.\n", suffix) + fmt.Fprintf(&sb, "- Delegate to `sdd-onboard%s` when the user asks for a guided end-to-end SDD walkthrough.\n\n", suffix) + + sb.WriteString("## Dispatch rules\n\n") + sb.WriteString("1. One phase at a time. Wait for the sub-agent to finish and return before dispatching the next phase.\n") + sb.WriteString("2. No skipping. If the user asks to jump phases, push back and explain why each phase is non-negotiable for a substantial change.\n") + sb.WriteString("3. Synthesize between phases. Give the user a one-line summary of what each phase produced before continuing.\n") + sb.WriteString("4. Stop on risk. If a phase returns CRITICAL findings or blockers, stop the chain and ask the user how to proceed.\n") + sb.WriteString("5. Pass forward, not back. Each phase reads prior artifacts via the persistence backend (Engram or OpenSpec). Pass topic keys / file paths, not artifact content.\n") + + return sb.String() +} + // phaseWithoutPrefix strips the "sdd-" prefix from a phase name for skill directory lookup. func phaseWithoutPrefix(phase string) string { return strings.TrimPrefix(phase, "sdd-") @@ -140,10 +215,11 @@ func SDDPhases() []string { return result } -// GenerateVSCodeProfileFiles writes 10 .agent.md files (one per SDD phase) -// for a named VS Code profile to the agents directory. Returns a list of written -// file paths. Default profile (name="" or name="default") is handled by the -// existing 3c block in inject.go and should NOT go through this function. +// GenerateVSCodeProfileFiles writes 11 .agent.md files (the orchestrator plus +// the 10 SDD phase executors) for a named VS Code profile to the agents +// directory. Returns the list of file paths that actually changed on disk. +// Default profile (name="" or name="default") is handled by the existing 3c +// block in inject.go and must NOT go through this function. func GenerateVSCodeProfileFiles(profile model.Profile, agentsDir string) ([]string, error) { if profile.Name == "" || profile.Name == "default" { return nil, fmt.Errorf("GenerateVSCodeProfileFiles: default profile is handled by the generic sub-agent path, not profile generation") @@ -155,7 +231,9 @@ func GenerateVSCodeProfileFiles(profile model.Profile, agentsDir string) ([]stri var files []string - for _, phase := range sddPhases { + // Render orchestrator first so it appears at the top of any directory listing. + allPhases := append([]string{OrchestratorPhase}, sddPhases...) + for _, phase := range allPhases { content := GenerateAgentFile(phase, profile) fileName := phase + "-" + profile.Name + ".agent.md" outPath := filepath.Join(agentsDir, fileName) diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go index 7ed50bbe6..71ca73023 100644 --- a/internal/assets/assets_test.go +++ b/internal/assets/assets_test.go @@ -458,6 +458,7 @@ func TestOpenCodeSDDOverlaySubagentsAreExplicitExecutors(t *testing.T) { func TestVSCodeAgentsEmbedded(t *testing.T) { expectedAgents := []string{ + "vscode/agents/sdd-orchestrator.agent.md", "vscode/agents/sdd-init.agent.md", "vscode/agents/sdd-explore.agent.md", "vscode/agents/sdd-propose.agent.md", 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..8d8d29ed3 --- /dev/null +++ b/internal/assets/vscode/agents/sdd-orchestrator.agent.md @@ -0,0 +1,60 @@ +--- +name: sdd-orchestrator{{VSC_PROFILE_SUFFIX}} +description: > + SDD workflow orchestrator — coordinates the 10 SDD phase executors in a strict, deterministic sequence. +model: {{VSC_MODEL}} +tools: ['agent'] +agents: + - sdd-init{{VSC_PROFILE_SUFFIX}} + - sdd-explore{{VSC_PROFILE_SUFFIX}} + - sdd-propose{{VSC_PROFILE_SUFFIX}} + - sdd-spec{{VSC_PROFILE_SUFFIX}} + - sdd-design{{VSC_PROFILE_SUFFIX}} + - sdd-tasks{{VSC_PROFILE_SUFFIX}} + - sdd-apply{{VSC_PROFILE_SUFFIX}} + - sdd-verify{{VSC_PROFILE_SUFFIX}} + - sdd-archive{{VSC_PROFILE_SUFFIX}} + - sdd-onboard{{VSC_PROFILE_SUFFIX}} +readonly: false +background: false +user-invocable: true +--- + +You are the SDD workflow orchestrator for the Gentleman AI ecosystem in VS Code Copilot. + +Your job is to coordinate the SDD phase executors in a strict, deterministic sequence. You do NOT perform phase work yourself — you delegate to the matching `sdd-*` sub-agent and synthesize their results back to the user. + +## SDD phase sequence — substantial changes + +For any non-trivial change (new feature, refactor, bug fix with design implications), drive the user through this exact sequence. Do NOT skip phases. + +1. **Explore** → delegate to `sdd-explore{{VSC_PROFILE_SUFFIX}}`. Survey the codebase, gather context, compare approaches. No files written yet. +2. **Propose** → delegate to `sdd-propose{{VSC_PROFILE_SUFFIX}}`. Draft a change proposal with intent, scope, and approach. +3. **Spec** → delegate to `sdd-spec{{VSC_PROFILE_SUFFIX}}`. Write requirements and acceptance scenarios derived from the proposal. +4. **Design** → delegate to `sdd-design{{VSC_PROFILE_SUFFIX}}`. Document the technical design and file-change plan. +5. **Tasks** → delegate to `sdd-tasks{{VSC_PROFILE_SUFFIX}}`. Break the change into an ordered task checklist. +6. **Apply** → delegate to `sdd-apply{{VSC_PROFILE_SUFFIX}}`. Implement the tasks. When Strict TDD is enabled, the executor follows the Red-Green-Refactor cycle. +7. **Verify** → delegate to `sdd-verify{{VSC_PROFILE_SUFFIX}}`. Validate the implementation against spec/design/tasks. Reports CRITICAL / WARNING / SUGGESTION findings. +8. **Archive** → delegate to `sdd-archive{{VSC_PROFILE_SUFFIX}}`. Sync delta specs into the main spec set and close the change. + +## SDD utility flows + +- **Init** → delegate to `sdd-init{{VSC_PROFILE_SUFFIX}}` when the project has not yet been initialized for SDD (detects stack, bootstraps persistence backend). +- **Onboard** → delegate to `sdd-onboard{{VSC_PROFILE_SUFFIX}}` when the user asks for a guided end-to-end SDD walkthrough using their own codebase. + +## Dispatch rules + +1. **One phase at a time.** Wait for the sub-agent to finish and return before dispatching the next phase. +2. **No skipping.** If the user asks to jump from Explore to Apply, push back: explain that Spec / Design / Tasks are non-negotiable for a substantial change. If the change is genuinely trivial, say so and skip SDD entirely instead. +3. **Synthesize between phases.** Give the user a one-line summary of what each phase produced before continuing. Do not assume they read the artifact. +4. **Stop on risk.** If a phase returns CRITICAL findings or blockers, stop the chain and ask the user how to proceed. Never plow through verification failures. +5. **Pass forward, not back.** Each phase reads the prior artifacts via the persistence backend (Engram or OpenSpec). Do not paste artifact content into prompts — pass the topic keys / file paths. + +## What you do not do + +- Implementation work. That belongs to `sdd-apply{{VSC_PROFILE_SUFFIX}}`. +- Validation work. That belongs to `sdd-verify{{VSC_PROFILE_SUFFIX}}`. +- Spec or design writing. Those belong to `sdd-spec{{VSC_PROFILE_SUFFIX}}` and `sdd-design{{VSC_PROFILE_SUFFIX}}`. +- Skipping the workflow because "it's faster." The whole point of SDD is the audit trail. If the user wants a freeform fix, they should not invoke this orchestrator. + +If you find yourself doing phase work directly, stop and delegate. diff --git a/internal/components/sdd/inject.go b/internal/components/sdd/inject.go index dfdc977d9..6b78c7469 100644 --- a/internal/components/sdd/inject.go +++ b/internal/components/sdd/inject.go @@ -658,6 +658,11 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt // remove the model line so Copilot uses its default. contentStr = strings.ReplaceAll(contentStr, "model: {{VSC_MODEL}}\n", "") } + + // Resolve {{VSC_PROFILE_SUFFIX}} placeholder. The default (unsuffixed) + // set always resolves to empty — named-profile suffixes are written + // by step 2c via GenerateVSCodeProfileFiles, not by step 3c. + contentStr = strings.ReplaceAll(contentStr, "{{VSC_PROFILE_SUFFIX}}", "") outPath := filepath.Join(agentsDir, entry.Name()) writeResult, err := filemerge.WriteFileAtomic(outPath, []byte(contentStr), 0o644) if err != nil { @@ -669,8 +674,21 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt } } - // Post-check: verify critical agent files exist (supports .md, .yaml, and .agent.md extensions) - for _, phase := range []string{"sdd-apply", "sdd-verify"} { + // Post-check: verify critical agent files exist (supports .md, .yaml, and .agent.md extensions). + // sdd-apply and sdd-verify are always critical — they are the executors + // whose absence would mask the most damage during a sync. sdd-orchestrator + // is critical only for adapters that ship it as a template (VS Code + // Copilot does; Claude Code does not — Claude uses CLAUDE.md as the root + // orchestrator prompt instead of a separate agent file). + criticalPhases := []string{"sdd-apply", "sdd-verify"} + for _, e := range entries { + name := e.Name() + if name == "sdd-orchestrator.agent.md" || name == "sdd-orchestrator.md" || name == "sdd-orchestrator.yaml" { + criticalPhases = append([]string{"sdd-orchestrator"}, criticalPhases...) + break + } + } + for _, phase := range criticalPhases { found := false for _, ext := range []string{".md", ".yaml", ".agent.md"} { checkPath := filepath.Join(agentsDir, phase+ext) diff --git a/internal/components/sdd/vscode_inject_test.go b/internal/components/sdd/vscode_inject_test.go index 31461c0d9..ecacfa418 100644 --- a/internal/components/sdd/vscode_inject_test.go +++ b/internal/components/sdd/vscode_inject_test.go @@ -190,8 +190,8 @@ func TestInject_VSCode_DefaultProfile_IsIdempotent(t *testing.T) { if err != nil { t.Fatalf("snapshotAgentFiles after first inject error = %v", err) } - if len(firstFiles) != 10 { - t.Fatalf("expected 10 default .agent.md files, got %d", len(firstFiles)) + if len(firstFiles) != 11 { + t.Fatalf("expected 11 default .agent.md files (orchestrator + 10 phases), got %d", len(firstFiles)) } second, err := Inject(home, vscodeAdapter, model.SDDModeMulti) @@ -242,9 +242,9 @@ func TestInject_VSCode_NamedProfile_IsIdempotent(t *testing.T) { if err != nil { t.Fatalf("snapshotAgentFiles after first inject error = %v", err) } - // Expect 20: 10 default unsuffixed + 10 "*-cheap.agent.md" - if len(firstFiles) != 20 { - t.Fatalf("expected 20 files (10 default + 10 cheap), got %d", len(firstFiles)) + // Expect 22: 11 default unsuffixed (orchestrator + 10 phases) + 11 "*-cheap.agent.md" + if len(firstFiles) != 22 { + t.Fatalf("expected 22 files (11 default + 11 cheap, each including orchestrator), got %d", len(firstFiles)) } second, err := Inject(home, vscodeAdapter, model.SDDModeMulti, opts) From 6948e2b6c92567047fd479912e2d5b34b4ac8cf8 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 22:53:59 +0200 Subject: [PATCH 8/9] feat(vscode-tui): add sdd-orchestrator row to VS Code model picker - Add VSCodeOrchestratorPhase const (re-export of vscodeagent.OrchestratorPhase) - Prepend orchestrator as Row 0 in VSCodeModelRows(); shift Set-all to Row 1, SDD phases to rows 2-11 (12 rows total, option count is now 14) - Update HandleVSCodeModelPickerNav: Row 0 assigns only to sdd-orchestrator key, Row 1 (Set all) assigns to 10 sub-agents but NOT orchestrator, rows 2-11 assign to individual phases - Update confirmVSCodeProfileCreateStep1: extract OrchestratorModel from assignments on Continue, copy only phase keys to PhaseAssignments - Add vscode_model_picker_test.go: 10 tests for row layout, constant value, option count, and nav semantics --- internal/tui/model.go | 15 +- internal/tui/screens/vscode_model_picker.go | 43 +++-- .../tui/screens/vscode_model_picker_test.go | 169 ++++++++++++++++++ 3 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 internal/tui/screens/vscode_model_picker_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index 86c162326..2e9188e61 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3503,11 +3503,11 @@ func (m Model) handleProfileNameInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // confirmVSCodeProfileCreateStep1 handles enter on step 1 of the VS Code profile -// create flow. Uses VSCodeModelPicker (static flat model list, no orchestrator row). +// create flow. Uses VSCodeModelPicker (Copilot-only catalog; orchestrator row + phase rows). func (m Model) confirmVSCodeProfileCreateStep1() (tea.Model, tea.Cmd) { rows := screens.VSCodeModelRows() if m.Cursor < len(rows) { - // Enter model select for the chosen phase row. + // Enter model select for the chosen row. m.VSCodeModelPicker.SelectedPhaseIdx = m.Cursor m.VSCodeModelPicker.Mode = screens.ModeModelSelect m.VSCodeModelPicker.ModelCursor = 0 @@ -3515,13 +3515,20 @@ func (m Model) confirmVSCodeProfileCreateStep1() (tea.Model, tea.Cmd) { return m, nil } if m.Cursor == len(rows) { - // "Continue": copy phase assignments to draft, advance. + // "Continue": extract orchestrator + phase assignments from selection, advance. if m.Selection.ModelAssignments != nil { + // Extract orchestrator model (key = sdd-orchestrator). + if orch, ok := m.Selection.ModelAssignments[screens.VSCodeOrchestratorPhase]; ok { + m.ProfileDraft.OrchestratorModel = orch + } + // Copy phase assignments (all keys except the orchestrator). if m.ProfileDraft.PhaseAssignments == nil { m.ProfileDraft.PhaseAssignments = make(map[string]model.ModelAssignment) } for k, v := range m.Selection.ModelAssignments { - m.ProfileDraft.PhaseAssignments[k] = v + if k != screens.VSCodeOrchestratorPhase { + m.ProfileDraft.PhaseAssignments[k] = v + } } } m.ProfileCreateStep = 2 diff --git a/internal/tui/screens/vscode_model_picker.go b/internal/tui/screens/vscode_model_picker.go index 520433721..6cb708508 100644 --- a/internal/tui/screens/vscode_model_picker.go +++ b/internal/tui/screens/vscode_model_picker.go @@ -61,11 +61,18 @@ func NewVSCodeModelPickerState(cachePath string) VSCodeModelPickerState { } } +// VSCodeOrchestratorPhase is the assignment key for the VS Code Copilot +// SDD orchestrator model. It mirrors vscodeagent.OrchestratorPhase and +// is re-exported here so model.go can reference it without importing the +// vscode agent package directly. +const VSCodeOrchestratorPhase = vscodeagent.OrchestratorPhase + // VSCodeModelRows returns the row labels for the VS Code model picker phase list. -// VS Code profiles have no orchestrator row (phases only). -// Row 0 is "Set all phases", rows 1-10 are the 10 SDD phases. +// Row 0 is the orchestrator (sdd-orchestrator), row 1 is "Set all phases", +// rows 2-11 are the 10 SDD phase executors. func VSCodeModelRows() []string { - rows := make([]string, 0, 11) + rows := make([]string, 0, 12) + rows = append(rows, vscodeagent.OrchestratorPhase) rows = append(rows, "Set all phases") rows = append(rows, vscodeagent.SDDPhases()...) return rows @@ -129,14 +136,24 @@ func renderVSCodePhaseList( focused := idx == cursor var label string - if idx == 0 { + switch { + case idx == 0: + // Row 0: sdd-orchestrator — individual assignment. + if assignment, ok := assignments[vscodeagent.OrchestratorPhase]; ok && assignment.ModelID != "" { + label = fmt.Sprintf("%-22s %s", row, assignment.ModelID) + } else { + label = fmt.Sprintf("%-22s (default)", row) + } + case idx == 1: + // Row 1: "Set all phases" — shows last bulk-set model. if state.AllPhasesModel != "" { label = fmt.Sprintf("%-22s (%s)", row, state.AllPhasesModel) } else { label = fmt.Sprintf("%-22s (not set)", row) } - } else { - phaseIdx := idx - 1 + default: + // Rows 2-11: individual SDD phases. + phaseIdx := idx - 2 if phaseIdx < len(phases) { phase := phases[phaseIdx] if assignment, ok := assignments[phase]; ok && assignment.ModelID != "" { @@ -255,13 +272,19 @@ func HandleVSCodeModelPickerNav( ModelID: entry.ID, } label := vscodeModelLabel(entry) - if state.SelectedPhaseIdx == 0 { + switch state.SelectedPhaseIdx { + case 0: + // Row 0: sdd-orchestrator — assign only to the orchestrator key. + assignments[vscodeagent.OrchestratorPhase] = assignment + case 1: + // Row 1: "Set all phases" — sets the 10 sub-agents, NOT the orchestrator. for _, phase := range phases { assignments[phase] = assignment } state.AllPhasesModel = label - } else { - phaseIdx := state.SelectedPhaseIdx - 1 + default: + // Rows 2-11: individual SDD phases. + phaseIdx := state.SelectedPhaseIdx - 2 if phaseIdx < len(phases) { assignments[phases[phaseIdx]] = assignment } @@ -281,7 +304,7 @@ func HandleVSCodeModelPickerNav( } // VSCodeModelPickerOptionCount returns the option count for the VS Code phase list. -// Rows + Continue + Back. +// Rows (1 orchestrator + 1 "Set all" + 10 phases) + Continue + Back = 14. func VSCodeModelPickerOptionCount() int { return len(VSCodeModelRows()) + 2 } diff --git a/internal/tui/screens/vscode_model_picker_test.go b/internal/tui/screens/vscode_model_picker_test.go new file mode 100644 index 000000000..cb0826088 --- /dev/null +++ b/internal/tui/screens/vscode_model_picker_test.go @@ -0,0 +1,169 @@ +package screens + +import ( + "testing" + + "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/opencode" +) + +// ── VSCodeModelRows ────────────────────────────────────────────────────────── + +func TestVSCodeModelRows_Count(t *testing.T) { + // 1 orchestrator + 1 "Set all phases" + 10 SDD phases = 12 rows. + rows := VSCodeModelRows() + if len(rows) != 12 { + t.Fatalf("VSCodeModelRows() len = %d, want 12", len(rows)) + } +} + +func TestVSCodeModelRows_OrchestratorIsFirst(t *testing.T) { + rows := VSCodeModelRows() + if rows[0] != VSCodeOrchestratorPhase { + t.Fatalf("VSCodeModelRows()[0] = %q, want %q", rows[0], VSCodeOrchestratorPhase) + } +} + +func TestVSCodeModelRows_SetAllPhasesIsSecond(t *testing.T) { + rows := VSCodeModelRows() + if rows[1] != "Set all phases" { + t.Fatalf("VSCodeModelRows()[1] = %q, want %q", rows[1], "Set all phases") + } +} + +func TestVSCodeModelRows_PhaseRowsStart_AtIndex2(t *testing.T) { + rows := VSCodeModelRows() + // rows[2] must be a real SDD phase, not orchestrator or Set-all. + if rows[2] == VSCodeOrchestratorPhase || rows[2] == "Set all phases" { + t.Fatalf("VSCodeModelRows()[2] = %q, expected a real SDD phase", rows[2]) + } +} + +// ── VSCodeOrchestratorPhase constant ──────────────────────────────────────── + +func TestVSCodeOrchestratorPhase_Value(t *testing.T) { + if VSCodeOrchestratorPhase != "sdd-orchestrator" { + t.Fatalf("VSCodeOrchestratorPhase = %q, want %q", VSCodeOrchestratorPhase, "sdd-orchestrator") + } +} + +// ── VSCodeModelPickerOptionCount ───────────────────────────────────────────── + +func TestVSCodeModelPickerOptionCount(t *testing.T) { + // 12 rows + Continue + Back = 14. + got := VSCodeModelPickerOptionCount() + if got != 14 { + t.Fatalf("VSCodeModelPickerOptionCount() = %d, want 14", got) + } +} + +// ── HandleVSCodeModelPickerNav ─────────────────────────────────────────────── + +func makeVSCodeTestState(selectedPhaseIdx int) VSCodeModelPickerState { + return VSCodeModelPickerState{ + Mode: ModeModelSelect, + SelectedPhaseIdx: selectedPhaseIdx, + Models: []opencode.Model{ + {ID: "claude-sonnet-4-20250514", Name: "Claude Sonnet 4"}, + }, + } +} + +// Row 0 (orchestrator) must assign only to the orchestrator key. +func TestHandleVSCodeModelNav_OrchestratorRow_AssignsOnlyOrchestrator(t *testing.T) { + state := VSCodeModelPickerState{ + Mode: ModeModelSelect, + SelectedPhaseIdx: 0, // orchestrator row + Models: []opencode.Model{{ID: "claude-sonnet-4-20250514", Name: "Claude Sonnet 4"}}, + } + assignments := map[string]model.ModelAssignment{} + + handled, updated := HandleVSCodeModelPickerNav("enter", &state, assignments) + if !handled { + t.Fatal("expected handled=true") + } + + // Orchestrator key must be set. + orch, ok := updated[VSCodeOrchestratorPhase] + if !ok { + t.Fatalf("expected %q to be assigned; assignments: %v", VSCodeOrchestratorPhase, updated) + } + if orch.ModelID != "claude-sonnet-4-20250514" { + t.Errorf("orchestrator ModelID = %q, want %q", orch.ModelID, "claude-sonnet-4-20250514") + } + + // No SDD phase must be assigned. + rows := VSCodeModelRows() + for _, phase := range rows[2:] { + if _, exists := updated[phase]; exists { + t.Errorf("phase %q should NOT be assigned when selecting orchestrator row; assignments: %v", phase, updated) + } + } +} + +// Row 1 ("Set all phases") must assign to 10 sub-agents but NOT the orchestrator. +func TestHandleVSCodeModelNav_SetAllPhasesRow_AssignsPhasesNotOrchestrator(t *testing.T) { + state := VSCodeModelPickerState{ + Mode: ModeModelSelect, + SelectedPhaseIdx: 1, // "Set all phases" row + Models: []opencode.Model{{ID: "gpt-4o", Name: "GPT-4o"}}, + } + assignments := map[string]model.ModelAssignment{} + + _, updated := HandleVSCodeModelPickerNav("enter", &state, assignments) + + // Orchestrator must NOT be set. + if _, exists := updated[VSCodeOrchestratorPhase]; exists { + t.Errorf("orchestrator should NOT be assigned by 'Set all phases'; assignments: %v", updated) + } + + // All 10 SDD phases must be set. + rows := VSCodeModelRows() + for _, phase := range rows[2:] { + if a, ok := updated[phase]; !ok || a.ModelID != "gpt-4o" { + t.Errorf("phase %q: ModelID = %q, want %q", phase, a.ModelID, "gpt-4o") + } + } +} + +// Row 1 "Set all phases" must NOT overwrite a pre-existing orchestrator assignment. +func TestHandleVSCodeModelNav_SetAllPhasesRow_DoesNotOverwriteExistingOrchestrator(t *testing.T) { + existing := model.ModelAssignment{ProviderID: "github-copilot", ModelID: "claude-sonnet-4-20250514"} + state := VSCodeModelPickerState{ + Mode: ModeModelSelect, + SelectedPhaseIdx: 1, // "Set all phases" + Models: []opencode.Model{{ID: "gpt-4o", Name: "GPT-4o"}}, + } + assignments := map[string]model.ModelAssignment{ + VSCodeOrchestratorPhase: existing, + } + + _, updated := HandleVSCodeModelPickerNav("enter", &state, assignments) + + orch := updated[VSCodeOrchestratorPhase] + if orch != existing { + t.Errorf("orchestrator assignment should be unchanged; got: %v", orch) + } +} + +// Row 2 (first SDD phase) must assign only to that phase. +func TestHandleVSCodeModelNav_PhaseRow_AssignsOnlyThatPhase(t *testing.T) { + state := VSCodeModelPickerState{ + Mode: ModeModelSelect, + SelectedPhaseIdx: 2, // first SDD phase (sdd-init) + Models: []opencode.Model{{ID: "gemini-2.5-pro", Name: "Gemini 2.5 Pro"}}, + } + assignments := map[string]model.ModelAssignment{} + + _, updated := HandleVSCodeModelPickerNav("enter", &state, assignments) + + // Orchestrator must not be touched. + if _, exists := updated[VSCodeOrchestratorPhase]; exists { + t.Errorf("orchestrator should not be assigned; assignments: %v", updated) + } + + // Exactly one phase must be assigned. + if len(updated) != 1 { + t.Errorf("expected 1 assigned phase, got %d; assignments: %v", len(updated), updated) + } +} From d716245ac04c06925b7729fb61dbdef73d4b4bb9 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 23:24:11 +0200 Subject: [PATCH 9/9] fix(assets): hide claude agents from vscode copilot UI Adds user-invocable: false frontmatter to Claude agents to prevent them from showing up as duplicates in VS Code Copilot's chat menu when both tools are installed, while keeping them functional for Claude Code. --- internal/assets/claude/agents/sdd-apply.md | 1 + internal/assets/claude/agents/sdd-archive.md | 1 + internal/assets/claude/agents/sdd-design.md | 1 + internal/assets/claude/agents/sdd-explore.md | 1 + internal/assets/claude/agents/sdd-propose.md | 1 + internal/assets/claude/agents/sdd-spec.md | 1 + internal/assets/claude/agents/sdd-tasks.md | 1 + internal/assets/claude/agents/sdd-verify.md | 1 + 8 files changed, 8 insertions(+) diff --git a/internal/assets/claude/agents/sdd-apply.md b/internal/assets/claude/agents/sdd-apply.md index c3d178282..afbbfcd31 100644 --- a/internal/assets/claude/agents/sdd-apply.md +++ b/internal/assets/claude/agents/sdd-apply.md @@ -1,5 +1,6 @@ --- name: sdd-apply +user-invocable: false 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 diff --git a/internal/assets/claude/agents/sdd-archive.md b/internal/assets/claude/agents/sdd-archive.md index 5170e3389..1e02d1c9a 100644 --- a/internal/assets/claude/agents/sdd-archive.md +++ b/internal/assets/claude/agents/sdd-archive.md @@ -1,5 +1,6 @@ --- name: sdd-archive +user-invocable: false 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, diff --git a/internal/assets/claude/agents/sdd-design.md b/internal/assets/claude/agents/sdd-design.md index f91b302b5..0942a53c6 100644 --- a/internal/assets/claude/agents/sdd-design.md +++ b/internal/assets/claude/agents/sdd-design.md @@ -1,5 +1,6 @@ --- name: sdd-design +user-invocable: false description: > Create the technical design document with architecture decisions and approach. Use when a proposal is approved and the implementation approach needs to be chosen before tasks are diff --git a/internal/assets/claude/agents/sdd-explore.md b/internal/assets/claude/agents/sdd-explore.md index 3461edb8b..f57a6eb4b 100644 --- a/internal/assets/claude/agents/sdd-explore.md +++ b/internal/assets/claude/agents/sdd-explore.md @@ -1,5 +1,6 @@ --- name: sdd-explore +user-invocable: false 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 diff --git a/internal/assets/claude/agents/sdd-propose.md b/internal/assets/claude/agents/sdd-propose.md index 800cb855d..4ff486432 100644 --- a/internal/assets/claude/agents/sdd-propose.md +++ b/internal/assets/claude/agents/sdd-propose.md @@ -1,5 +1,6 @@ --- name: sdd-propose +user-invocable: false 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. diff --git a/internal/assets/claude/agents/sdd-spec.md b/internal/assets/claude/agents/sdd-spec.md index b4a53d1a8..ac07ce185 100644 --- a/internal/assets/claude/agents/sdd-spec.md +++ b/internal/assets/claude/agents/sdd-spec.md @@ -1,5 +1,6 @@ --- name: sdd-spec +user-invocable: false description: > Write specifications with requirements and scenarios. Use when a proposal is approved and the change needs formal requirements (delta specs) captured before implementation. diff --git a/internal/assets/claude/agents/sdd-tasks.md b/internal/assets/claude/agents/sdd-tasks.md index 1fa70afa5..59b362631 100644 --- a/internal/assets/claude/agents/sdd-tasks.md +++ b/internal/assets/claude/agents/sdd-tasks.md @@ -1,5 +1,6 @@ --- name: sdd-tasks +user-invocable: false 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. diff --git a/internal/assets/claude/agents/sdd-verify.md b/internal/assets/claude/agents/sdd-verify.md index 1813b84c4..f636dade9 100644 --- a/internal/assets/claude/agents/sdd-verify.md +++ b/internal/assets/claude/agents/sdd-verify.md @@ -1,5 +1,6 @@ --- name: sdd-verify +user-invocable: false 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.