From 354e0664052526c5060511879174ca274219b198 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 17:52:35 +0200 Subject: [PATCH 1/5] 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 88707da6ea866d91397fefa3666608fbf5482133 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 21:45:00 +0200 Subject: [PATCH 2/5] 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 b8896775da90bc4c6585234711da7262f4da02ef Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 22:14:45 +0200 Subject: [PATCH 3/5] 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 35c3378c3b3123e08cac772315cd4565edf0bc26 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 22:39:57 +0200 Subject: [PATCH 4/5] 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 d28d7393e383c14bf8a1827e06e2033236fdb40b Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Mon, 11 May 2026 23:14:25 +0200 Subject: [PATCH 5/5] fix(agents): normalize CRLF before sentinel replacement in sub-agent templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embedded .agent.md templates may contain CRLF line endings on Windows. The sentinel removal (e.g. model: {{VSC_MODEL}}\n → empty) only matched LF, silently leaving raw {{VSC_MODEL}} in the deployed files. VS Code Copilot's YAML parser then rejects the invalid model value. Normalize \r\n → \n immediately after reading the embedded content, before any sentinel replacement runs. --- internal/components/sdd/inject.go | 7 ++++ internal/components/sdd/inject_test.go | 46 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/internal/components/sdd/inject.go b/internal/components/sdd/inject.go index 6b78c7469..e190a5841 100644 --- a/internal/components/sdd/inject.go +++ b/internal/components/sdd/inject.go @@ -601,6 +601,13 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt // Copy all files (not just .md) to support Kimi's YAML-based agents contentStr := assets.MustRead(embeddedDir + "/" + entry.Name()) + // Normalize line endings to LF before sentinel replacement. + // Embedded templates may contain CRLF (e.g. Windows checkouts). + // Without normalization, replacements like "model: {{VSC_MODEL}}\n" + // silently fail because they only match LF — leaving raw sentinels + // in the output file, which causes YAML parse errors in Copilot. + contentStr = strings.ReplaceAll(contentStr, "\r\n", "\n") + // Resolve {{KIRO_MODEL}} placeholder for adapters that support it (e.g. Kiro). // Non-Kiro adapters (Cursor, etc.) don't implement kiroModelResolver and are unaffected. if kmr, ok := adapter.(kiroModelResolver); ok { diff --git a/internal/components/sdd/inject_test.go b/internal/components/sdd/inject_test.go index 4cb0a8f99..58e1d9aef 100644 --- a/internal/components/sdd/inject_test.go +++ b/internal/components/sdd/inject_test.go @@ -926,6 +926,52 @@ func TestInjectVSCodeWritesSDDOrchestratorAndSkills(t *testing.T) { } } +func TestInjectVSCodeStripsModelSentinelEvenWithCRLF(t *testing.T) { + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + vscodeAdapter, err := agents.NewAdapter("vscode-copilot") + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + + // Inject with NO model assignments — this is the default path where + // VSCModelID returns "" and the sentinel must be removed entirely. + result, injectErr := Inject(home, vscodeAdapter, "") + if injectErr != nil { + t.Fatalf("Inject(vscode) error = %v", injectErr) + } + if !result.Changed { + t.Fatal("Inject(vscode) changed = false") + } + + // Verify NO agent file contains the raw {{VSC_MODEL}} sentinel. + // This was a real bug: templates with CRLF line endings caused + // strings.ReplaceAll("model: {{VSC_MODEL}}\n", "") to miss the + // \r\n variant, leaving the raw placeholder in the output. + agentsDir := vscodeAdapter.SubAgentsDir(home) + entries, err := os.ReadDir(agentsDir) + if err != nil { + t.Fatalf("ReadDir(%q) error = %v", agentsDir, err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + content, readErr := os.ReadFile(filepath.Join(agentsDir, entry.Name())) + if readErr != nil { + t.Fatalf("ReadFile(%q) error = %v", entry.Name(), readErr) + } + if strings.Contains(string(content), "{{VSC_MODEL}}") { + t.Fatalf("agent %q still contains raw {{VSC_MODEL}} sentinel — CRLF normalization failed", entry.Name()) + } + if strings.Contains(string(content), "{{VSC_PROFILE_SUFFIX}}") { + t.Fatalf("agent %q still contains raw {{VSC_PROFILE_SUFFIX}} sentinel", entry.Name()) + } + } +} + func TestInjectFileAppendSkipsIfAlreadyPresent(t *testing.T) { home := t.TempDir()