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/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/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 new file mode 100644 index 000000000..bbb0daad9 --- /dev/null +++ b/internal/agents/vscode/vscode_profiles.go @@ -0,0 +1,375 @@ +package vscode + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "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", +} + +// 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 + } + + description := sddPhaseDescriptions[phase] + if description == "" { + description = "SDD " + phase + " executor" + } + + var sb strings.Builder + sb.WriteString("---\n") + fmt.Fprintf(&sb, "name: %s\n", agentName) + fmt.Fprintf(&sb, "description: >\n %s\n", description) + + if assignment, ok := profile.PhaseAssignments[phase]; ok { + if modelID := VSCodeModelID(assignment); modelID != "" { + fmt.Fprintf(&sb, "model: \"%s\"\n", modelID) + } + } + + sb.WriteString("readonly: false\n") + sb.WriteString("background: false\n") + sb.WriteString("user-invocable: false\n") + sb.WriteString("---\n\n") + + 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") + 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-") +} + +// 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 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") + } + + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + return nil, fmt.Errorf("create agents dir: %w", err) + } + + var files []string + + // 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) + + 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 +} + +// DetectVSCodeProfiles scans agentsDir for sdd-{phase}-{name}.agent.md files +// and returns deduplicated, sorted []model.Profile. An empty or missing +// directory is not an error — callers treat it as "no profiles yet". +func DetectVSCodeProfiles(agentsDir string) ([]model.Profile, error) { + entries, err := os.ReadDir(agentsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read agents dir: %w", err) + } + + seen := make(map[string]struct{}) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + // Must match sdd-{phase}-{profileName}.agent.md + // Strategy: strip known phase prefixes and .agent.md suffix to extract profile name. + profileName := extractProfileName(name) + if profileName == "" { + continue + } + seen[profileName] = struct{}{} + } + + if len(seen) == 0 { + return nil, nil + } + + profiles := make([]model.Profile, 0, len(seen)) + for name := range seen { + profiles = append(profiles, model.Profile{Name: name}) + } + + // Sort deterministically by name. + sort.Slice(profiles, func(i, j int) bool { + return profiles[i].Name < profiles[j].Name + }) + + return profiles, nil +} + +// extractProfileName parses a filename of the form sdd-{phase}-{profileName}.agent.md +// and returns the profile name. Returns "" if the file does not match the pattern. +func extractProfileName(filename string) string { + const suffix = ".agent.md" + if !strings.HasSuffix(filename, suffix) { + return "" + } + if !strings.HasPrefix(filename, "sdd-") { + return "" + } + // Strip suffix + base := filename[:len(filename)-len(suffix)] + // Try to match sdd-{phase}-{name}: look for a known phase prefix + for _, phase := range sddPhases { + phasePrefix := phase + "-" + if strings.HasPrefix(base, phasePrefix) { + profileName := base[len(phasePrefix):] + if profileName != "" { + return profileName + } + } + } + return "" +} + +// 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_detect_test.go b/internal/agents/vscode/vscode_profiles_detect_test.go new file mode 100644 index 000000000..73d65f1b1 --- /dev/null +++ b/internal/agents/vscode/vscode_profiles_detect_test.go @@ -0,0 +1,116 @@ +package vscode + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectVSCodeProfiles_HappyPath(t *testing.T) { + agentsDir := t.TempDir() + + // Write 10 sdd-*-cheap.agent.md + 10 sdd-*-fast.agent.md files + for _, phase := range sddPhases { + for _, profileName := range []string{"cheap", "fast"} { + fname := phase + "-" + profileName + ".agent.md" + if err := os.WriteFile(filepath.Join(agentsDir, fname), []byte("content"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", fname, err) + } + } + } + + profiles, err := DetectVSCodeProfiles(agentsDir) + if err != nil { + t.Fatalf("DetectVSCodeProfiles() error = %v", err) + } + if len(profiles) != 2 { + t.Fatalf("DetectVSCodeProfiles() returned %d profiles, want 2", len(profiles)) + } + // Must be sorted by name + if profiles[0].Name != "cheap" { + t.Errorf("profiles[0].Name = %q, want %q", profiles[0].Name, "cheap") + } + if profiles[1].Name != "fast" { + t.Errorf("profiles[1].Name = %q, want %q", profiles[1].Name, "fast") + } +} + +func TestDetectVSCodeProfiles_EmptyDir(t *testing.T) { + agentsDir := t.TempDir() + + profiles, err := DetectVSCodeProfiles(agentsDir) + if err != nil { + t.Fatalf("DetectVSCodeProfiles() on empty dir error = %v", err) + } + if len(profiles) != 0 { + t.Fatalf("DetectVSCodeProfiles() returned %d profiles on empty dir, want 0", len(profiles)) + } +} + +func TestDetectVSCodeProfiles_MissingDir(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist") + + profiles, err := DetectVSCodeProfiles(missing) + if err != nil { + t.Fatalf("DetectVSCodeProfiles() on missing dir error = %v (want nil)", err) + } + if len(profiles) != 0 { + t.Fatalf("DetectVSCodeProfiles() returned %d profiles on missing dir, want 0", len(profiles)) + } +} + +func TestDetectVSCodeProfiles_IgnoresNonSDDFiles(t *testing.T) { + agentsDir := t.TempDir() + + // Write non-SDD files that must be ignored + noiseFiles := []string{ + "my-custom.agent.md", + ".DS_Store", + "notes.txt", + "readme.md", + } + for _, f := range noiseFiles { + if err := os.WriteFile(filepath.Join(agentsDir, f), []byte("noise"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", f, err) + } + } + // Write one valid sdd profile file + if err := os.WriteFile(filepath.Join(agentsDir, "sdd-apply-myprofile.agent.md"), []byte("content"), 0o644); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + profiles, err := DetectVSCodeProfiles(agentsDir) + if err != nil { + t.Fatalf("DetectVSCodeProfiles() error = %v", err) + } + if len(profiles) != 1 { + t.Fatalf("DetectVSCodeProfiles() returned %d profiles, want 1", len(profiles)) + } + if profiles[0].Name != "myprofile" { + t.Errorf("profiles[0].Name = %q, want %q", profiles[0].Name, "myprofile") + } +} + +func TestDetectVSCodeProfiles_DefaultFilesExcluded(t *testing.T) { + agentsDir := t.TempDir() + + // Unsuffixed default files must NOT be counted as profiles + defaultFiles := []string{ + "sdd-apply.agent.md", + "sdd-verify.agent.md", + "sdd-init.agent.md", + } + for _, f := range defaultFiles { + if err := os.WriteFile(filepath.Join(agentsDir, f), []byte("default"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", f, err) + } + } + + profiles, err := DetectVSCodeProfiles(agentsDir) + if err != nil { + t.Fatalf("DetectVSCodeProfiles() error = %v", err) + } + if len(profiles) != 0 { + t.Fatalf("DetectVSCodeProfiles() returned %d profiles for default files only, want 0", len(profiles)) + } +} diff --git a/internal/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..71ca73023 100644 --- a/internal/assets/assets_test.go +++ b/internal/assets/assets_test.go @@ -456,6 +456,47 @@ 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", + "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/claude/agents/sdd-apply.md b/internal/assets/claude/agents/sdd-apply.md index c3d178282..afbbfcd31 100644 --- a/internal/assets/claude/agents/sdd-apply.md +++ b/internal/assets/claude/agents/sdd-apply.md @@ -1,5 +1,6 @@ --- name: sdd-apply +user-invocable: false description: > Implement code changes from task definitions. Use when tasks are ready and implementation should begin. Reads spec, design, and tasks artifacts, then writes code following existing diff --git a/internal/assets/claude/agents/sdd-archive.md b/internal/assets/claude/agents/sdd-archive.md index 5170e3389..1e02d1c9a 100644 --- a/internal/assets/claude/agents/sdd-archive.md +++ b/internal/assets/claude/agents/sdd-archive.md @@ -1,5 +1,6 @@ --- name: sdd-archive +user-invocable: false description: > Archive a completed and verified change. Use when verification has passed and the change needs to be closed — merges delta specs into main specs, moves change folder to archive, diff --git a/internal/assets/claude/agents/sdd-design.md b/internal/assets/claude/agents/sdd-design.md index f91b302b5..0942a53c6 100644 --- a/internal/assets/claude/agents/sdd-design.md +++ b/internal/assets/claude/agents/sdd-design.md @@ -1,5 +1,6 @@ --- name: sdd-design +user-invocable: false description: > Create the technical design document with architecture decisions and approach. Use when a proposal is approved and the implementation approach needs to be chosen before tasks are diff --git a/internal/assets/claude/agents/sdd-explore.md b/internal/assets/claude/agents/sdd-explore.md index 3461edb8b..f57a6eb4b 100644 --- a/internal/assets/claude/agents/sdd-explore.md +++ b/internal/assets/claude/agents/sdd-explore.md @@ -1,5 +1,6 @@ --- name: sdd-explore +user-invocable: false description: > Explore and investigate ideas before committing to a change. Use when asked to think through a feature, investigate the codebase, understand current architecture, compare approaches, or diff --git a/internal/assets/claude/agents/sdd-propose.md b/internal/assets/claude/agents/sdd-propose.md index 800cb855d..4ff486432 100644 --- a/internal/assets/claude/agents/sdd-propose.md +++ b/internal/assets/claude/agents/sdd-propose.md @@ -1,5 +1,6 @@ --- name: sdd-propose +user-invocable: false description: > Create a change proposal with intent, scope, and approach. Use when exploration is complete and the idea is ready to be formalized into a proposal document. diff --git a/internal/assets/claude/agents/sdd-spec.md b/internal/assets/claude/agents/sdd-spec.md index b4a53d1a8..ac07ce185 100644 --- a/internal/assets/claude/agents/sdd-spec.md +++ b/internal/assets/claude/agents/sdd-spec.md @@ -1,5 +1,6 @@ --- name: sdd-spec +user-invocable: false description: > Write specifications with requirements and scenarios. Use when a proposal is approved and the change needs formal requirements (delta specs) captured before implementation. diff --git a/internal/assets/claude/agents/sdd-tasks.md b/internal/assets/claude/agents/sdd-tasks.md index 1fa70afa5..59b362631 100644 --- a/internal/assets/claude/agents/sdd-tasks.md +++ b/internal/assets/claude/agents/sdd-tasks.md @@ -1,5 +1,6 @@ --- name: sdd-tasks +user-invocable: false description: > Break down a change into an implementation task checklist. Use when spec and design are both ready and the change needs to be sliced into actionable, ordered work items. diff --git a/internal/assets/claude/agents/sdd-verify.md b/internal/assets/claude/agents/sdd-verify.md index 1813b84c4..f636dade9 100644 --- a/internal/assets/claude/agents/sdd-verify.md +++ b/internal/assets/claude/agents/sdd-verify.md @@ -1,5 +1,6 @@ --- name: sdd-verify +user-invocable: false description: > Validate that implementation matches specs, design, and tasks. Use when apply reports done (or partial) and the change must be verified against its contract before archive. 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-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/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..6b78c7469 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,39 @@ 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", "") + } + + // 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 { @@ -611,10 +674,23 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt } } - // Post-check: verify critical agent files exist (either .md or .yaml) - 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"} { + 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..ecacfa418 --- /dev/null +++ b/internal/components/sdd/vscode_inject_test.go @@ -0,0 +1,312 @@ +package sdd + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gentleman-programming/gentle-ai/internal/agents" + "github.com/gentleman-programming/gentle-ai/internal/agents/opencode" + "github.com/gentleman-programming/gentle-ai/internal/model" +) + +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) + } +} + +// 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) != 11 { + t.Fatalf("expected 11 default .agent.md files (orchestrator + 10 phases), 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 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) + 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 diff --git a/internal/tui/model.go b/internal/tui/model.go index fead87e9b..2e9188e61 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/gentleman-programming/gentle-ai/internal/agentbuilder" + "github.com/gentleman-programming/gentle-ai/internal/agents/vscode" "github.com/gentleman-programming/gentle-ai/internal/backup" "github.com/gentleman-programming/gentle-ai/internal/catalog" "github.com/gentleman-programming/gentle-ai/internal/components/opencodeplugin" @@ -51,6 +52,18 @@ var readProfilesFn = func(settingsPath string) ([]model.Profile, error) { return sdd.DetectProfiles(settingsPath) } +// readVSCodeProfilesFn is a package-level variable so tests can override how +// VS Code profiles are detected from the agents directory. +var readVSCodeProfilesFn = func(agentsDir string) ([]model.Profile, error) { + return vscode.DetectVSCodeProfiles(agentsDir) +} + +// vscodeAgentsDirFn returns the path to the VS Code Copilot agents directory. +// Package-level so tests can override it. +var vscodeAgentsDirFn = func() string { + return filepath.Join(homeDir(), ".copilot", "agents") +} + // TickMsg drives the spinner animation on the installing screen. type TickMsg time.Time @@ -194,6 +207,7 @@ const ( ScreenClaudeModelPicker ScreenKiroModelPicker ScreenSDDMode + ScreenSDDDuplicateAgentsWarning ScreenStrictTDD ScreenOpenCodePlugins ScreenOpenCodePluginResult @@ -248,9 +262,10 @@ type Model struct { Progress ProgressState Execution pipeline.ExecutionResult Backups []backup.Manifest - ModelPicker screens.ModelPickerState - ClaudeModelPicker screens.ClaudeModelPickerState - KiroModelPicker screens.KiroModelPickerState + ModelPicker screens.ModelPickerState + VSCodeModelPicker screens.VSCodeModelPickerState + ClaudeModelPicker screens.ClaudeModelPickerState + KiroModelPicker screens.KiroModelPickerState SkillPicker []model.SkillID Err error @@ -365,6 +380,14 @@ type Model struct { ProfileNameCollision bool // true when name collides with existing profile (awaiting second enter to overwrite) ProfileDeleteErr error // error from the last RemoveProfileAgents call, displayed on ScreenProfiles + // VSCodeProfileList holds the VS Code SDD profiles detected from ~/.copilot/agents/. + VSCodeProfileList []model.Profile + + // ActiveProfileAdapter identifies which adapter's profile screen is currently + // shown. Set when the user selects a profiles entry from the welcome menu. + // Empty means OpenCode (the default); model.AgentVSCodeCopilot means VS Code. + ActiveProfileAdapter model.AgentID + // UninstallMode holds the selected uninstall mode (partial, full, full-remove). UninstallMode model.UninstallMode @@ -557,6 +580,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } // else keep existing list + // Sync doesn't change VS Code files, but refresh the list cheaply to keep + // it in sync with any out-of-band changes. + if m.hasDetectedVSCode() { + if vscProfiles, err := readVSCodeProfilesFn(vscodeAgentsDirFn()); err == nil { + m.VSCodeProfileList = vscProfiles + } + } return m, nil case UninstallDoneMsg: m.OperationRunning = false @@ -688,7 +718,7 @@ func (m Model) View() string { if m.UpdateCheckDone && update.HasUpdates(m.UpdateResults) { banner = "Updates available: " + update.UpdateSummaryLine(m.UpdateResults) } - return screens.RenderWelcome(m.Cursor, m.Version, banner, m.UpdateResults, m.UpdateCheckDone, m.hasDetectedOpenCode(), len(m.ProfileList), m.hasAgentBuilderEngines()) + return screens.RenderWelcome(m.Cursor, m.Version, banner, m.UpdateResults, m.UpdateCheckDone, m.hasDetectedOpenCode(), len(m.ProfileList), m.hasAgentBuilderEngines(), m.hasDetectedVSCode(), len(m.VSCodeProfileList)) case ScreenUpgrade: return screens.RenderUpgrade(m.UpdateResults, m.UpgradeReport, m.UpgradeErr, m.OperationRunning, m.UpdateCheckDone, m.Cursor, m.SpinnerFrame) case ScreenSync: @@ -696,8 +726,22 @@ func (m Model) View() string { case ScreenModelConfig: return screens.RenderModelConfig(m.Cursor) case ScreenProfiles: - return screens.RenderProfiles(m.ProfileList, m.Cursor, m.ProfileDeleteErr) + profiles, adapterLabel := m.activeProfiles() + return screens.RenderProfiles(profiles, m.Cursor, m.ProfileDeleteErr, adapterLabel) case ScreenProfileCreate: + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + return screens.RenderVSCodeProfileCreate( + m.ProfileCreateStep, + m.ProfileDraft, + m.ProfileNameInput, + m.ProfileNamePos, + m.ProfileNameErr, + m.ProfileEditMode, + m.Selection.ModelAssignments, + m.VSCodeModelPicker, + m.Cursor, + ) + } return screens.RenderProfileCreate( m.ProfileCreateStep, m.ProfileDraft, @@ -710,7 +754,7 @@ func (m Model) View() string { m.Cursor, ) case ScreenProfileDelete: - return screens.RenderProfileDelete(m.ProfileDeleteTarget, m.Cursor) + return screens.RenderProfileDelete(m.ProfileDeleteTarget, m.Cursor, m.ActiveProfileAdapter == model.AgentVSCodeCopilot) case ScreenUpgradeSync: return screens.RenderUpgradeSync(m.UpdateResults, m.UpgradeReport, m.SyncFilesChanged, m.UpgradeErr, m.SyncErr, m.OperationRunning, m.UpdateCheckDone, m.Cursor, m.SpinnerFrame) case ScreenUninstallMode: @@ -739,6 +783,8 @@ func (m Model) View() string { return screens.RenderKiroModelPicker(m.KiroModelPicker, m.Cursor) case ScreenSDDMode: return screens.RenderSDDMode(m.Selection.SDDMode, m.Cursor) + case ScreenSDDDuplicateAgentsWarning: + return screens.RenderSDDDuplicateAgentsWarning(m.Cursor) case ScreenStrictTDD: return screens.RenderStrictTDD(m.Selection.StrictTDD, m.Cursor) case ScreenOpenCodePlugins: @@ -813,13 +859,22 @@ func (m Model) handleKeyPress(key tea.KeyMsg) (tea.Model, tea.Cmd) { } } - // Profile create step 1 reuses the ModelPicker sub-modes (provider/model drill-down). - if (m.Screen == ScreenProfileCreate && m.ProfileCreateStep == 1) && - m.ModelPicker.Mode != screens.ModePhaseList { - handled, updated := screens.HandleModelPickerNav(keyStr, &m.ModelPicker, m.Selection.ModelAssignments) - if handled { - m.Selection.ModelAssignments = updated - return m, nil + // Profile create step 1 — delegate to the correct model picker sub-mode. + if m.Screen == ScreenProfileCreate && m.ProfileCreateStep == 1 { + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + if m.VSCodeModelPicker.Mode != screens.ModePhaseList { + handled, updated := screens.HandleVSCodeModelPickerNav(keyStr, &m.VSCodeModelPicker, m.Selection.ModelAssignments) + if handled { + m.Selection.ModelAssignments = updated + return m, nil + } + } + } else if m.ModelPicker.Mode != screens.ModePhaseList { + handled, updated := screens.HandleModelPickerNav(keyStr, &m.ModelPicker, m.Selection.ModelAssignments) + if handled { + m.Selection.ModelAssignments = updated + return m, nil + } } } @@ -1133,6 +1188,20 @@ func (m Model) confirmSelection() (tea.Model, tea.Cmd) { if m.hasDetectedOpenCode() { if m.Cursor == next { + m.ActiveProfileAdapter = model.AgentOpenCode + m.setScreen(ScreenProfiles) + return m, nil + } + next++ + } + + if m.hasDetectedVSCode() { + if m.Cursor == next { + m.ActiveProfileAdapter = model.AgentVSCodeCopilot + // Refresh VS Code profile list on entry. + if profiles, err := readVSCodeProfilesFn(vscodeAgentsDirFn()); err == nil { + m.VSCodeProfileList = profiles + } m.setScreen(ScreenProfiles) return m, nil } @@ -1324,29 +1393,37 @@ func (m Model) confirmSelection() (tea.Model, tea.Cmd) { m.OperationMode = "upgrade-sync" return m, tea.Batch(tickCmd(), m.startUpgradeSync()) case ScreenProfiles: - // Profiles are: 0..len(ProfileList)-1, then Create, then Back. - profileCount := len(m.ProfileList) + // Profiles are: 0..len(profiles)-1, then Create, then Back. + profiles, _ := m.activeProfiles() + profileCount := len(profiles) switch { case m.Cursor < profileCount: // Edit an existing profile. - profile := m.ProfileList[m.Cursor] + profile := profiles[m.Cursor] m.ProfileEditMode = true m.ProfileDraft = profile m.ProfileCreateStep = 0 m.ProfileNameInput = profile.Name m.ProfileNamePos = len([]rune(profile.Name)) m.ProfileNameErr = "" - // Build ModelAssignments from the profile's phase assignments + orchestrator. - // The ModelPicker shows gentle-orchestrator as the base row, so we need - // to include it in the map for it to display the current model. - assignments := make(map[string]model.ModelAssignment) - for k, v := range profile.PhaseAssignments { - assignments[k] = v - } - if profile.OrchestratorModel.ProviderID != "" { - assignments[screens.SDDOrchestratorPhase] = profile.OrchestratorModel + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + // VS Code edit: no orchestrator model, just phase assignments. + assignments := make(map[string]model.ModelAssignment) + for k, v := range profile.PhaseAssignments { + assignments[k] = v + } + m.Selection.ModelAssignments = assignments + } else { + // OpenCode: include orchestrator model in assignments for the picker. + assignments := make(map[string]model.ModelAssignment) + for k, v := range profile.PhaseAssignments { + assignments[k] = v + } + if profile.OrchestratorModel.ProviderID != "" { + assignments[screens.SDDOrchestratorPhase] = profile.OrchestratorModel + } + m.Selection.ModelAssignments = assignments } - m.Selection.ModelAssignments = assignments m.setScreen(ScreenProfileCreate) case m.Cursor == profileCount: // "Create new profile" @@ -1367,17 +1444,33 @@ func (m Model) confirmSelection() (tea.Model, tea.Cmd) { return m.confirmProfileCreate() case ScreenProfileDelete: switch m.Cursor { - case 0: // "Delete & Sync" - if err := sdd.RemoveProfileAgents(opencode.DefaultSettingsPath(), m.ProfileDeleteTarget); err != nil { - // Store the error so it can be displayed on ScreenProfiles. - m.ProfileDeleteErr = err + case 0: // "Delete & Sync" (OpenCode) / "Delete" (VS Code) + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + // VS Code: remove agent files directly, no sync needed. + if err := vscode.RemoveVSCodeProfileAgents(vscodeAgentsDirFn(), m.ProfileDeleteTarget); err != nil { + m.ProfileDeleteErr = err + m.setScreen(ScreenProfiles) + return m, nil + } + m.ProfileDeleteErr = nil + // Refresh VS Code profile list. + if profiles, err := readVSCodeProfilesFn(vscodeAgentsDirFn()); err == nil { + m.VSCodeProfileList = profiles + } m.setScreen(ScreenProfiles) } else { - m.ProfileDeleteErr = nil - m.PendingSyncOverrides = nil - m = m.withResetSyncState() - m.setScreen(ScreenSync) - return m, tea.Batch(tickCmd(), m.startSync(nil)) + // OpenCode: sync pipeline. + if err := sdd.RemoveProfileAgents(opencode.DefaultSettingsPath(), m.ProfileDeleteTarget); err != nil { + // Store the error so it can be displayed on ScreenProfiles. + m.ProfileDeleteErr = err + m.setScreen(ScreenProfiles) + } else { + m.ProfileDeleteErr = nil + m.PendingSyncOverrides = nil + m = m.withResetSyncState() + m.setScreen(ScreenSync) + return m, tea.Batch(tickCmd(), m.startSync(nil)) + } } default: // "Cancel" m.setScreen(ScreenProfiles) @@ -1513,46 +1606,14 @@ func (m Model) confirmSelection() (tea.Model, tea.Cmd) { options := screens.SDDModeOptions() if m.Cursor < len(options) { m.Selection.SDDMode = options[m.Cursor] - if m.Selection.SDDMode == model.SDDModeMulti { - cachePath := opencode.DefaultCachePath() - if _, err := osStatModelCache(cachePath); err == nil { - // Cache exists — OpenCode has been run at least once. - // Show the model picker so the user can assign models. - m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) - m.Selection.ModelAssignments = nil - m.setScreen(ScreenModelPicker) - return m, nil - } - // Cache missing — OpenCode hasn't been run yet on this machine. - // Skip the model picker; models will use OpenCode defaults. - // The picker empty-state message explains what to do after install. - m.ModelPicker = screens.ModelPickerState{} - } - // Clear assignments for both single mode and multi-no-cache paths. - m.Selection.ModelAssignments = nil - // Show StrictTDD screen when OpenCode + SDD are selected. - // This is the next step before the dependency tree. - if m.shouldShowSDDModeScreen() { - m.setScreen(ScreenStrictTDD) + // Surface duplicate-agent warning when SDD multi-mode is paired with + // adapter combinations that VS Code Copilot will surface as duplicate + // entries (e.g. VS Code Copilot + Claude Code). + if m.Selection.SDDMode == model.SDDModeMulti && m.shouldWarnAboutDuplicateAgents() { + m.setScreen(ScreenSDDDuplicateAgentsWarning) return m, nil } - if m.Selection.Preset == model.PresetCustom { - // Custom preset: dependency plan was already built before SDD mode. - // Check skill picker before going to review. - if m.shouldShowSkillPickerScreen() { - if len(m.SkillPicker) == 0 { - m.initSkillPicker() - } - m.setScreen(ScreenSkillPicker) - } else { - m.Review = planner.BuildReviewPayload(m.Selection, m.DependencyPlan) - m.setScreen(ScreenReview) - } - } else { - m.buildDependencyPlan() - m.setScreen(ScreenDependencyTree) - } - return m, nil + return m.advanceFromSDDModeSelection() } // Back — in custom preset, return to ClaudeModelPicker if applicable, // otherwise DependencyTree (component selector). @@ -1571,6 +1632,18 @@ func (m Model) confirmSelection() (tea.Model, tea.Cmd) { m.setScreen(ScreenPreset) } } + case ScreenSDDDuplicateAgentsWarning: + options := screens.SDDDuplicateAgentsWarningOptions() + if m.Cursor < len(options) { + if m.Cursor == 0 { + // "Continue anyway" — resume the normal post-SDDMode flow. + return m.advanceFromSDDModeSelection() + } + // "Back to adapter selection" — return to SDDMode so the user + // can reconsider their adapter set. + m.setScreen(ScreenSDDMode) + } + return m, nil case ScreenModelPicker: // When no providers are detected the screen only shows a "Back" option // at cursor 0. Handle that before the normal row logic. @@ -2541,18 +2614,27 @@ func (m *Model) setScreen(next Screen) { if next == ScreenProfiles { // Clear stale delete error so it is not shown after Cancel/Esc from ScreenProfileDelete. m.ProfileDeleteErr = nil - // Refresh profile list on entry. Surface errors via m.Err so callers can react. - profiles, err := readProfilesFn(opencode.DefaultSettingsPath()) - if err != nil { - m.Err = err - m.ProfileList = nil + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + // Refresh VS Code profile list on entry. + if profiles, err := readVSCodeProfilesFn(vscodeAgentsDirFn()); err == nil { + m.VSCodeProfileList = profiles + } + if m.Cursor >= len(m.VSCodeProfileList) { + m.Cursor = 0 + } } else { - m.ProfileList = profiles - } - // Clamp cursor so it never points past the end of a refreshed list. - // m.Cursor was just reset to 0 above, so this only triggers if ProfileList is empty. - if m.Cursor >= len(m.ProfileList) { - m.Cursor = 0 + // Refresh OpenCode profile list on entry. + profiles, err := readProfilesFn(opencode.DefaultSettingsPath()) + if err != nil { + m.Err = err + m.ProfileList = nil + } else { + m.ProfileList = profiles + } + // Clamp cursor so it never points past the end of a refreshed list. + if m.Cursor >= len(m.ProfileList) { + m.Cursor = 0 + } } } if next == ScreenUninstallMode { @@ -2613,7 +2695,7 @@ func (m Model) handleRenameInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) optionCount() int { switch m.Screen { case ScreenWelcome: - return len(screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, m.hasDetectedOpenCode(), len(m.ProfileList), m.hasAgentBuilderEngines())) + return len(screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, m.hasDetectedOpenCode(), len(m.ProfileList), m.hasAgentBuilderEngines(), m.hasDetectedVSCode(), len(m.VSCodeProfileList))) case ScreenUpgrade: if m.UpgradeReport != nil || m.UpgradeErr != nil { return 1 // "return" option in results/error state @@ -2658,6 +2740,8 @@ func (m Model) optionCount() int { return screens.KiroModelPickerOptionCount(m.KiroModelPicker) case ScreenSDDMode: return len(screens.SDDModeOptions()) + 1 + case ScreenSDDDuplicateAgentsWarning: + return len(screens.SDDDuplicateAgentsWarningOptions()) case ScreenStrictTDD: return len(screens.StrictTDDOptions()) + 1 // Enable + Disable + Back case ScreenOpenCodePlugins: @@ -2695,8 +2779,12 @@ func (m Model) optionCount() int { case ScreenRenameBackup: return 0 // text input mode — no cursor navigation case ScreenProfiles: - return screens.ProfileListOptionCount(m.ProfileList) + profiles, _ := m.activeProfiles() + return screens.ProfileListOptionCount(profiles) case ScreenProfileCreate: + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + return screens.VSCodeProfileCreateOptionCount(m.ProfileCreateStep) + } return screens.ProfileCreateOptionCount(m.ProfileCreateStep, m.ModelPicker) case ScreenProfileDelete: return screens.ProfileDeleteOptionCount() @@ -3145,11 +3233,88 @@ func (m Model) hasDetectedOpenCode() bool { return false } +// hasDetectedVSCode returns true if VS Code Copilot config directory was detected. +func (m Model) hasDetectedVSCode() bool { + for _, cfg := range m.Detection.Configs { + if cfg.Agent == string(model.AgentVSCodeCopilot) && cfg.Exists { + return true + } + } + return false +} + +// activeProfiles returns the profile list and display label for the currently +// active adapter. Used by View() and optionCount() to branch on adapter. +func (m Model) activeProfiles() ([]model.Profile, string) { + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + return m.VSCodeProfileList, "VS Code" + } + return m.ProfileList, "OpenCode" +} + func (m Model) shouldShowSDDModeScreen() bool { return m.Selection.HasAgent(model.AgentOpenCode) && hasSelectedComponent(m.Selection.Components, model.ComponentSDD) } +// shouldWarnAboutDuplicateAgents reports whether the active selection will +// surface visually duplicated sub-agent entries in VS Code Copilot's Agent +// customizations panel. VS Code Copilot scans `.agent.md` (its native +// format) and Claude-format `.md` agents in parallel, so when both +// VS Code Copilot and a Claude-format adapter ship SDD sub-agents the user +// sees each phase twice. +// +// Today only Claude Code writes the conflicting `.md` format from the +// gentle-ai installer. Extend this list when other adapters start shipping +// Claude-format agent files. +func (m Model) shouldWarnAboutDuplicateAgents() bool { + if !hasSelectedComponent(m.Selection.Components, model.ComponentSDD) { + return false + } + if !m.Selection.HasAgent(model.AgentVSCodeCopilot) { + return false + } + return m.Selection.HasAgent(model.AgentClaudeCode) +} + +// advanceFromSDDModeSelection executes the navigation that follows after +// the user has chosen an SDDMode value. It is called directly from the +// ScreenSDDMode handler when no warning is required, and from the +// ScreenSDDDuplicateAgentsWarning handler when the user opts to continue +// past the warning. +func (m Model) advanceFromSDDModeSelection() (tea.Model, tea.Cmd) { + if m.Selection.SDDMode == model.SDDModeMulti { + cachePath := opencode.DefaultCachePath() + if _, err := osStatModelCache(cachePath); err == nil { + m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) + m.Selection.ModelAssignments = nil + m.setScreen(ScreenModelPicker) + return m, nil + } + m.ModelPicker = screens.ModelPickerState{} + } + m.Selection.ModelAssignments = nil + if m.shouldShowSDDModeScreen() { + m.setScreen(ScreenStrictTDD) + return m, nil + } + if m.Selection.Preset == model.PresetCustom { + if m.shouldShowSkillPickerScreen() { + if len(m.SkillPicker) == 0 { + m.initSkillPicker() + } + m.setScreen(ScreenSkillPicker) + } else { + m.Review = planner.BuildReviewPayload(m.Selection, m.DependencyPlan) + m.setScreen(ScreenReview) + } + } else { + m.buildDependencyPlan() + m.setScreen(ScreenDependencyTree) + } + return m, nil +} + // shouldShowStrictTDDScreen reports whether the Strict TDD Mode screen should // be shown in the navigation flow. It requires only that the SDD component is // selected — the screen is agent-agnostic. @@ -3284,9 +3449,13 @@ func (m Model) handleProfileNameInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.ProfileNameCollision = false m.ProfileDraft.Name = name m.ProfileCreateStep = 1 - // Initialize model picker for orchestrator step. + // Initialize model picker for step 1. VS Code uses the github-copilot + // catalog from the OpenCode cache; OpenCode uses the full multi-provider + // picker. cachePath := opencode.DefaultCachePath() - if _, err := osStatModelCache(cachePath); err == nil { + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + m.VSCodeModelPicker = screens.NewVSCodeModelPickerState(cachePath) + } else if _, err := osStatModelCache(cachePath); err == nil { m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) } else { m.ModelPicker = screens.ModelPickerState{} @@ -3333,6 +3502,51 @@ func (m Model) handleProfileNameInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// confirmVSCodeProfileCreateStep1 handles enter on step 1 of the VS Code profile +// create flow. Uses VSCodeModelPicker (Copilot-only catalog; orchestrator row + phase rows). +func (m Model) confirmVSCodeProfileCreateStep1() (tea.Model, tea.Cmd) { + rows := screens.VSCodeModelRows() + if m.Cursor < len(rows) { + // Enter model select for the chosen row. + m.VSCodeModelPicker.SelectedPhaseIdx = m.Cursor + m.VSCodeModelPicker.Mode = screens.ModeModelSelect + m.VSCodeModelPicker.ModelCursor = 0 + m.VSCodeModelPicker.ModelScroll = 0 + return m, nil + } + if m.Cursor == len(rows) { + // "Continue": extract orchestrator + phase assignments from selection, advance. + if m.Selection.ModelAssignments != nil { + // Extract orchestrator model (key = sdd-orchestrator). + if orch, ok := m.Selection.ModelAssignments[screens.VSCodeOrchestratorPhase]; ok { + m.ProfileDraft.OrchestratorModel = orch + } + // Copy phase assignments (all keys except the orchestrator). + if m.ProfileDraft.PhaseAssignments == nil { + m.ProfileDraft.PhaseAssignments = make(map[string]model.ModelAssignment) + } + for k, v := range m.Selection.ModelAssignments { + if k != screens.VSCodeOrchestratorPhase { + m.ProfileDraft.PhaseAssignments[k] = v + } + } + } + m.ProfileCreateStep = 2 + m.Cursor = 0 + return m, nil + } + if m.Cursor == len(rows)+1 { + // "Back" + if m.ProfileEditMode { + m.setScreen(ScreenProfiles) + } else { + m.ProfileCreateStep = 0 + m.Cursor = 0 + } + } + return m, nil +} + // confirmProfileCreate handles enter key presses on ScreenProfileCreate. // Step 0 (name input) is handled by handleProfileNameInput for create mode. // Steps: 0=name, 1=assign models (orchestrator + sub-agents), 2=confirm. @@ -3343,7 +3557,9 @@ func (m Model) confirmProfileCreate() (tea.Model, tea.Cmd) { if m.ProfileEditMode { m.ProfileCreateStep = 1 cachePath := opencode.DefaultCachePath() - if _, err := osStatModelCache(cachePath); err == nil { + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + m.VSCodeModelPicker = screens.NewVSCodeModelPickerState(cachePath) + } else if _, err := osStatModelCache(cachePath); err == nil { m.ModelPicker = screens.NewModelPickerState(cachePath, opencode.DefaultSettingsPath()) } else { m.ModelPicker = screens.ModelPickerState{} @@ -3352,8 +3568,10 @@ func (m Model) confirmProfileCreate() (tea.Model, tea.Cmd) { } return m, nil case 1: - // Model assignment picker: orchestrator + all sub-agent phases in one screen. - // Reuse the same enter-on-row logic as ScreenModelPicker. + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + return m.confirmVSCodeProfileCreateStep1() + } + // OpenCode: model assignment picker with orchestrator + sub-agent phases. rows := screens.ModelPickerRows() if m.Cursor < len(rows) { // Enter sub-selection: pick provider then model. @@ -3396,8 +3614,23 @@ func (m Model) confirmProfileCreate() (tea.Model, tea.Cmd) { default: // Step 2: confirm. switch m.Cursor { - case 0: // "Create & Sync" / "Save & Sync" + case 0: // "Create & Sync" / "Save & Sync" / "Create" (VS Code) draft := m.ProfileDraft + if m.ActiveProfileAdapter == model.AgentVSCodeCopilot { + // VS Code: write files directly, no sync needed. + if _, err := vscode.GenerateVSCodeProfileFiles(draft, vscodeAgentsDirFn()); err != nil { + m.ProfileDeleteErr = err + m.setScreen(ScreenProfiles) + return m, nil + } + // Refresh VS Code profile list. + if profiles, err := readVSCodeProfilesFn(vscodeAgentsDirFn()); err == nil { + m.VSCodeProfileList = profiles + } + m.setScreen(ScreenProfiles) + return m, nil + } + // OpenCode: sync pipeline. m.PendingSyncOverrides = &model.SyncOverrides{ Profiles: []model.Profile{draft}, } diff --git a/internal/tui/model_profiles_vscode_test.go b/internal/tui/model_profiles_vscode_test.go new file mode 100644 index 000000000..371e8be99 --- /dev/null +++ b/internal/tui/model_profiles_vscode_test.go @@ -0,0 +1,344 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/system" + "github.com/gentleman-programming/gentle-ai/internal/tui/screens" +) + +// newModelWithVSCodeDetected returns a Model that reports VS Code Copilot as detected. +func newModelWithVSCodeDetected() Model { + m := NewModel(system.DetectionResult{ + Configs: []system.ConfigState{ + {Agent: string(model.AgentVSCodeCopilot), Exists: true}, + }, + }, "dev") + return m +} + +// newModelWithBothDetected returns a Model with both OpenCode and VS Code detected. +func newModelWithBothDetected() Model { + m := NewModel(system.DetectionResult{ + Configs: []system.ConfigState{ + {Agent: string(model.AgentOpenCode), Exists: true}, + {Agent: string(model.AgentVSCodeCopilot), Exists: true}, + }, + }, "dev") + return m +} + +// TestHasDetectedVSCode verifies the detection flag based on Detection.Configs. +func TestHasDetectedVSCode(t *testing.T) { + tests := []struct { + name string + configs []system.ConfigState + want bool + }{ + { + name: "vscode detected and exists", + configs: []system.ConfigState{{Agent: string(model.AgentVSCodeCopilot), Exists: true}}, + want: true, + }, + { + name: "vscode present but not exists", + configs: []system.ConfigState{{Agent: string(model.AgentVSCodeCopilot), Exists: false}}, + want: false, + }, + { + name: "opencode detected, not vscode", + configs: []system.ConfigState{{Agent: string(model.AgentOpenCode), Exists: true}}, + want: false, + }, + { + name: "empty configs", + configs: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewModel(system.DetectionResult{Configs: tt.configs}, "dev") + if got := m.hasDetectedVSCode(); got != tt.want { + t.Errorf("hasDetectedVSCode() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestWelcomeMenuShowsVSCodeProfiles verifies that the VS Code profile entry +// appears in the welcome menu when VS Code is detected. +func TestWelcomeMenuShowsVSCodeProfiles(t *testing.T) { + m := newModelWithVSCodeDetected() + m.VSCodeProfileList = []model.Profile{{Name: "cheap"}, {Name: "fast"}} + + view := m.View() + + if !strings.Contains(view, "VS Code SDD Profiles (2)") { + t.Errorf("welcome view missing 'VS Code SDD Profiles (2)', got:\n%s", view) + } +} + +// TestWelcomeMenuHidesVSCodeProfilesWhenNotDetected ensures the entry is absent +// when VS Code is not detected. +func TestWelcomeMenuHidesVSCodeProfilesWhenNotDetected(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.VSCodeProfileList = nil + + view := m.View() + + if strings.Contains(view, "VS Code SDD Profiles") { + t.Errorf("welcome view should NOT contain 'VS Code SDD Profiles' when not detected, got:\n%s", view) + } +} + +// TestActiveProfileAdapter_SetOnWelcomeClick verifies that clicking the VS Code profiles +// menu item sets ActiveProfileAdapter to AgentVSCodeCopilot and transitions to ScreenProfiles. +func TestActiveProfileAdapter_SetOnWelcomeClick(t *testing.T) { + m := newModelWithVSCodeDetected() + m.Screen = ScreenWelcome + + // Compute which cursor index is the VS Code profiles entry. + // Menu: 0=Install, 1=Upgrade, 2=Sync, 3=Upgrade+Sync, 4=ModelConfig, + // 5=AgentBuilder, 6=Plugins, 7=VSCodeProfiles(since OpenCode not detected), 8=Backups, 9=Uninstall, 10=Quit + opts := screens.WelcomeOptions(nil, false, false, 0, false, true, 0) + vscodeIdx := -1 + for i, opt := range opts { + if strings.HasPrefix(opt, "VS Code SDD Profiles") { + vscodeIdx = i + break + } + } + if vscodeIdx == -1 { + t.Fatal("VS Code SDD Profiles not found in WelcomeOptions") + } + + m.Cursor = vscodeIdx + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + if state.Screen != ScreenProfiles { + t.Errorf("screen = %v, want ScreenProfiles", state.Screen) + } + if state.ActiveProfileAdapter != model.AgentVSCodeCopilot { + t.Errorf("ActiveProfileAdapter = %q, want %q", state.ActiveProfileAdapter, model.AgentVSCodeCopilot) + } +} + +// TestVSCodeProfileCreate_GeneratesFiles verifies that confirming a VS Code profile +// create writes 10 agent files and refreshes VSCodeProfileList (no sync triggered). +func TestVSCodeProfileCreate_GeneratesFiles(t *testing.T) { + agentsDir := t.TempDir() + + // Override the readVSCodeProfilesFn so it reads from our temp dir. + restore := overrideReadVSCodeProfilesFn(agentsDir) + defer restore() + + // Override vscodeAgentsDirFn to point at temp dir. + restoreDir := overrideVSCodeAgentsDirFn(agentsDir) + defer restoreDir() + + m := newModelWithVSCodeDetected() + m.Screen = ScreenProfileCreate + m.ActiveProfileAdapter = model.AgentVSCodeCopilot + m.ProfileCreateStep = 2 + m.ProfileEditMode = false + m.ProfileDraft = model.Profile{ + Name: "testprofile", + PhaseAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "anthropic", ModelID: "claude-sonnet-4-20250514"}, + }, + } + m.Cursor = 0 // "Create" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + // Must stay on ScreenProfiles, not ScreenSync + if state.Screen != ScreenProfiles { + t.Errorf("screen = %v, want ScreenProfiles (no sync for VS Code)", state.Screen) + } + + // 10 files must exist in agentsDir + entries, err := os.ReadDir(agentsDir) + if err != nil { + t.Fatalf("ReadDir(%q) error = %v", agentsDir, err) + } + if len(entries) != 10 { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("expected 10 agent files, got %d: %v", len(entries), names) + } + + // Profile list should be refreshed + if len(state.VSCodeProfileList) == 0 { + t.Error("VSCodeProfileList should be refreshed after create, but is empty") + } +} + +// TestVSCodeProfileDelete_RemovesFiles_NoSync verifies that delete removes agent files +// and does NOT trigger a sync operation. +func TestVSCodeProfileDelete_RemovesFiles_NoSync(t *testing.T) { + agentsDir := t.TempDir() + + // Write 10 sdd-*-cheap.agent.md files + sddPhases := []string{ + "sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", + "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard", + } + for _, phase := range sddPhases { + fname := phase + "-cheap.agent.md" + if err := os.WriteFile(filepath.Join(agentsDir, fname), []byte("content"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", fname, err) + } + } + + restoreDir := overrideVSCodeAgentsDirFn(agentsDir) + defer restoreDir() + restoreRead := overrideReadVSCodeProfilesFn(agentsDir) + defer restoreRead() + + m := newModelWithVSCodeDetected() + m.Screen = ScreenProfileDelete + m.ActiveProfileAdapter = model.AgentVSCodeCopilot + m.ProfileDeleteTarget = "cheap" + m.Cursor = 0 // "Delete" button + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + // Must return to ScreenProfiles, not ScreenSync + if state.Screen != ScreenProfiles { + t.Errorf("screen = %v, want ScreenProfiles (no sync for VS Code delete)", state.Screen) + } + // OperationRunning must NOT be set (no sync launched) + if state.OperationRunning { + t.Error("OperationRunning should be false after VS Code delete (no sync)") + } + // Files must be gone + for _, phase := range sddPhases { + fname := phase + "-cheap.agent.md" + if _, err := os.Stat(filepath.Join(agentsDir, fname)); !os.IsNotExist(err) { + t.Errorf("file %q should have been removed", fname) + } + } +} + +// TestRenderProfiles_AdapterLabel verifies that the profiles screen title reflects +// the active adapter. +func TestRenderProfiles_AdapterLabel(t *testing.T) { + tests := []struct { + name string + adapterLabel string + wantTitle string + }{ + {"opencode adapter", "OpenCode", "OpenCode SDD Profiles"}, + {"vscode adapter", "VS Code", "VS Code SDD Profiles"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + view := screens.RenderProfiles(nil, 0, nil, tt.adapterLabel) + if !strings.Contains(view, tt.wantTitle) { + t.Errorf("RenderProfiles with adapterLabel=%q missing %q in output:\n%s", + tt.adapterLabel, tt.wantTitle, view) + } + }) + } +} + +// TestRenderProfileDelete_VSCodeWording verifies the wording adapts for VS Code. +func TestRenderProfileDelete_VSCodeWording(t *testing.T) { + t.Run("opencode wording", func(t *testing.T) { + view := screens.RenderProfileDelete("myprofile", 0, false) + if !strings.Contains(view, "Delete & Sync") { + t.Errorf("OpenCode delete should show 'Delete & Sync', got:\n%s", view) + } + }) + + t.Run("vscode wording", func(t *testing.T) { + view := screens.RenderProfileDelete("myprofile", 0, true) + if !strings.Contains(view, "10 agent files") { + t.Errorf("VS Code delete should mention '10 agent files', got:\n%s", view) + } + if strings.Contains(view, "Delete & Sync") { + t.Errorf("VS Code delete should NOT show 'Delete & Sync', got:\n%s", view) + } + }) +} + +// TestWelcomeMenuBothDetected verifies both profile entries appear when both adapters detected. +func TestWelcomeMenuBothDetected(t *testing.T) { + m := newModelWithBothDetected() + m.ProfileList = []model.Profile{{Name: "oc-profile"}} + m.VSCodeProfileList = []model.Profile{{Name: "vsc-profile"}} + + view := m.View() + + if !strings.Contains(view, "OpenCode SDD Profiles (1)") { + t.Errorf("welcome view missing 'OpenCode SDD Profiles (1)', got:\n%s", view) + } + if !strings.Contains(view, "VS Code SDD Profiles (1)") { + t.Errorf("welcome view missing 'VS Code SDD Profiles (1)', got:\n%s", view) + } +} + +// --- helpers for test injection --- + +func overrideReadVSCodeProfilesFn(agentsDir string) func() { + original := readVSCodeProfilesFn + readVSCodeProfilesFn = func(dir string) ([]model.Profile, error) { + // count sdd-*-{name}.agent.md files and return profile names + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + seen := make(map[string]struct{}) + phases := []string{ + "sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", + "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard", + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".agent.md") || !strings.HasPrefix(name, "sdd-") { + continue + } + base := name[:len(name)-len(".agent.md")] + for _, phase := range phases { + prefix := phase + "-" + if strings.HasPrefix(base, prefix) { + profileName := base[len(prefix):] + if profileName != "" { + seen[profileName] = struct{}{} + } + } + } + } + result := make([]model.Profile, 0, len(seen)) + for n := range seen { + result = append(result, model.Profile{Name: n}) + } + return result, nil + } + return func() { readVSCodeProfilesFn = original } +} + +func overrideVSCodeAgentsDirFn(dir string) func() { + original := vscodeAgentsDirFn + vscodeAgentsDirFn = func() string { return dir } + return func() { vscodeAgentsDirFn = original } +} diff --git a/internal/tui/model_sdd_duplicate_warning_test.go b/internal/tui/model_sdd_duplicate_warning_test.go new file mode 100644 index 000000000..6ca9c167f --- /dev/null +++ b/internal/tui/model_sdd_duplicate_warning_test.go @@ -0,0 +1,204 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/system" + "github.com/gentleman-programming/gentle-ai/internal/tui/screens" +) + +// TestShouldWarnAboutDuplicateAgents covers the detection helper that fires +// the warning screen when SDD multi-mode is paired with VS Code Copilot +// AND a Claude-format adapter. +func TestShouldWarnAboutDuplicateAgents(t *testing.T) { + tests := []struct { + name string + agents []model.AgentID + components []model.ComponentID + want bool + }{ + { + name: "vscode + claude + sdd → warn", + agents: []model.AgentID{model.AgentVSCodeCopilot, model.AgentClaudeCode}, + components: []model.ComponentID{model.ComponentSDD}, + want: true, + }, + { + name: "vscode + claude WITHOUT sdd → no warn", + agents: []model.AgentID{model.AgentVSCodeCopilot, model.AgentClaudeCode}, + components: nil, + want: false, + }, + { + name: "vscode alone + sdd → no warn", + agents: []model.AgentID{model.AgentVSCodeCopilot}, + components: []model.ComponentID{model.ComponentSDD}, + want: false, + }, + { + name: "claude alone + sdd → no warn", + agents: []model.AgentID{model.AgentClaudeCode}, + components: []model.ComponentID{model.ComponentSDD}, + want: false, + }, + { + name: "opencode + claude + sdd → no warn (no vscode)", + agents: []model.AgentID{model.AgentOpenCode, model.AgentClaudeCode}, + components: []model.ComponentID{model.ComponentSDD}, + want: false, + }, + { + name: "vscode + opencode + sdd → no warn (no claude)", + agents: []model.AgentID{model.AgentVSCodeCopilot, model.AgentOpenCode}, + components: []model.ComponentID{model.ComponentSDD}, + want: false, + }, + { + name: "no agents at all", + agents: nil, + components: []model.ComponentID{model.ComponentSDD}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.Selection.Agents = tt.agents + m.Selection.Components = tt.components + if got := m.shouldWarnAboutDuplicateAgents(); got != tt.want { + t.Errorf("shouldWarnAboutDuplicateAgents() = %v, want %v (agents=%v, components=%v)", + got, tt.want, tt.agents, tt.components) + } + }) + } +} + +// TestSDDMode_TriggersDuplicateAgentsWarning verifies that selecting SDD +// multi-mode with the VS Code + Claude combination routes to the warning +// screen instead of the normal next screen. +func TestSDDMode_TriggersDuplicateAgentsWarning(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.Selection.Agents = []model.AgentID{model.AgentVSCodeCopilot, model.AgentClaudeCode} + m.Selection.Components = []model.ComponentID{model.ComponentSDD} + m.Screen = ScreenSDDMode + + // SDD mode options: [Single, Multi]. Cursor 1 → Multi. + options := screens.SDDModeOptions() + multiIdx := -1 + for i, opt := range options { + if opt == model.SDDModeMulti { + multiIdx = i + } + } + if multiIdx < 0 { + t.Fatal("SDDModeMulti option not found in SDDModeOptions()") + } + m.Cursor = multiIdx + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + if state.Screen != ScreenSDDDuplicateAgentsWarning { + t.Fatalf("after selecting Multi with vscode+claude, screen = %v, want ScreenSDDDuplicateAgentsWarning", state.Screen) + } + if state.Selection.SDDMode != model.SDDModeMulti { + t.Errorf("SDDMode = %v, want %v", state.Selection.SDDMode, model.SDDModeMulti) + } +} + +// TestSDDMode_NoWarningWhenNotDuplicating verifies that the warning is NOT +// shown when the adapter set does not trigger duplication (e.g. VS Code +// without Claude). +func TestSDDMode_NoWarningWhenNotDuplicating(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.Selection.Agents = []model.AgentID{model.AgentVSCodeCopilot} + m.Selection.Components = []model.ComponentID{model.ComponentSDD} + m.Selection.Preset = model.PresetMinimal + m.Screen = ScreenSDDMode + + options := screens.SDDModeOptions() + multiIdx := -1 + for i, opt := range options { + if opt == model.SDDModeMulti { + multiIdx = i + } + } + m.Cursor = multiIdx + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + if state.Screen == ScreenSDDDuplicateAgentsWarning { + t.Fatalf("warning fired without claude adapter; screen = %v", state.Screen) + } +} + +// TestSDDDuplicateAgentsWarning_ContinueAdvances verifies that pressing +// Enter on "Continue anyway" resumes the normal SDDMode flow. +func TestSDDDuplicateAgentsWarning_ContinueAdvances(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.Selection.Agents = []model.AgentID{model.AgentVSCodeCopilot, model.AgentClaudeCode} + m.Selection.Components = []model.ComponentID{model.ComponentSDD} + m.Selection.SDDMode = model.SDDModeMulti + m.Selection.Preset = model.PresetMinimal + m.Screen = ScreenSDDDuplicateAgentsWarning + m.Cursor = 0 // "Continue anyway" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + if state.Screen == ScreenSDDDuplicateAgentsWarning { + t.Fatal("after 'Continue anyway', screen still ScreenSDDDuplicateAgentsWarning — advance did not fire") + } + if state.Screen == ScreenSDDMode { + t.Fatal("after 'Continue anyway', screen returned to SDDMode — should advance forward") + } +} + +// TestSDDDuplicateAgentsWarning_BackReturnsToSDDMode verifies that the +// "Back" option returns to the SDDMode selection so the user can change +// their mind. +func TestSDDDuplicateAgentsWarning_BackReturnsToSDDMode(t *testing.T) { + m := NewModel(system.DetectionResult{}, "dev") + m.Selection.Agents = []model.AgentID{model.AgentVSCodeCopilot, model.AgentClaudeCode} + m.Selection.Components = []model.ComponentID{model.ComponentSDD} + m.Selection.SDDMode = model.SDDModeMulti + m.Screen = ScreenSDDDuplicateAgentsWarning + m.Cursor = 1 // "Back to adapter selection" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + state := updated.(Model) + + if state.Screen != ScreenSDDMode { + t.Fatalf("after 'Back', screen = %v, want ScreenSDDMode", state.Screen) + } +} + +// TestRenderSDDDuplicateAgentsWarning_ListsExpectedPhases verifies that the +// rendered output names the 8 phases that visibly duplicate. Documents the +// list as part of the contract, so a future contributor cannot silently +// drop one without updating the test. +func TestRenderSDDDuplicateAgentsWarning_ListsExpectedPhases(t *testing.T) { + output := screens.RenderSDDDuplicateAgentsWarning(0) + expected := []string{ + "sdd-apply", "sdd-archive", "sdd-design", "sdd-explore", + "sdd-propose", "sdd-spec", "sdd-tasks", "sdd-verify", + } + for _, phase := range expected { + if !strings.Contains(output, phase) { + t.Errorf("warning output missing duplicated phase %q", phase) + } + } + // The two phases that don't duplicate must NOT be in the list. + for _, phase := range []string{"sdd-init", "sdd-onboard"} { + // Allow them only as part of the prose ("you're installing SDD…"), + // not as bullet-list entries. The bullet entries are prefixed with "•". + if strings.Contains(output, "• "+phase) { + t.Errorf("warning output incorrectly lists non-duplicating phase %q as duplicated", phase) + } + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index e0a18d175..c5917368d 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1040,12 +1040,12 @@ func TestWelcomeMenu_UninstallNavigation_WithProfiles(t *testing.T) { func TestWelcomeMenu_OptionCount(t *testing.T) { m := NewModel(system.DetectionResult{}, "dev") // Without OpenCode detected: 10 options (includes dedicated OpenCode community plugins and managed uninstall). - opts := screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, false, 0, true) + opts := screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, false, 0, true, false, 0) if len(opts) != 10 { t.Fatalf("WelcomeOptions(showProfiles=false) len = %d, want 10; got %v", len(opts), opts) } // With OpenCode detected: 11 options (adds "OpenCode SDD Profiles"). - optsWithProfiles := screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, true, 0, true) + optsWithProfiles := screens.WelcomeOptions(m.UpdateResults, m.UpdateCheckDone, true, 0, true, false, 0) if len(optsWithProfiles) != 11 { t.Fatalf("WelcomeOptions(showProfiles=true) len = %d, want 11; got %v", len(optsWithProfiles), optsWithProfiles) } diff --git a/internal/tui/router.go b/internal/tui/router.go index 441d7b738..cdaeb443e 100644 --- a/internal/tui/router.go +++ b/internal/tui/router.go @@ -14,6 +14,7 @@ var linearRoutes = map[Screen]Route{ ScreenClaudeModelPicker: {Forward: ScreenDependencyTree, Backward: ScreenPreset}, ScreenKiroModelPicker: {Forward: ScreenDependencyTree, Backward: ScreenPreset}, ScreenSDDMode: {Forward: ScreenStrictTDD, Backward: ScreenPreset}, + ScreenSDDDuplicateAgentsWarning: {Forward: ScreenStrictTDD, Backward: ScreenSDDMode}, ScreenStrictTDD: {Forward: ScreenDependencyTree, Backward: ScreenSDDMode}, ScreenOpenCodePluginResult: {Backward: ScreenWelcome}, ScreenModelPicker: {Forward: ScreenStrictTDD, Backward: ScreenSDDMode}, diff --git a/internal/tui/screens/profile_delete.go b/internal/tui/screens/profile_delete.go index 46ef0893e..ef7959ac7 100644 --- a/internal/tui/screens/profile_delete.go +++ b/internal/tui/screens/profile_delete.go @@ -9,9 +9,9 @@ import ( ) // RenderProfileDelete renders the profile delete confirmation screen. -// It shows the profile name, the 11 agent keys that will be removed, and -// "Delete & Sync" / "Cancel" options. -func RenderProfileDelete(profileName string, cursor int) string { +// When isVSCode is false, shows OpenCode wording (11 agent keys, "Delete & Sync"). +// When isVSCode is true, shows VS Code wording (10 agent files, "Delete"). +func RenderProfileDelete(profileName string, cursor int, isVSCode bool) string { var b strings.Builder b.WriteString(styles.TitleStyle.Render("Delete Profile")) @@ -20,24 +20,45 @@ func RenderProfileDelete(profileName string, cursor int) string { b.WriteString(styles.WarningStyle.Render(fmt.Sprintf("Are you sure you want to delete profile %q?", profileName))) b.WriteString("\n\n") - b.WriteString(styles.SubtextStyle.Render("The following 11 agent keys will be removed from opencode.json:")) - b.WriteString("\n\n") + if isVSCode { + b.WriteString(styles.SubtextStyle.Render("The following 10 agent files will be removed from ~/.copilot/agents/:")) + b.WriteString("\n\n") - // Show orchestrator key. - b.WriteString(styles.UnselectedStyle.Render(" • sdd-orchestrator-" + profileName)) - b.WriteString("\n") + vscodePhases := []string{ + "sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", + "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard", + } + for _, phase := range vscodePhases { + b.WriteString(styles.UnselectedStyle.Render(" • " + phase + "-" + profileName + ".agent.md")) + b.WriteString("\n") + } - // Show phase keys using the canonical phase list from the sdd package. - for _, phase := range sdd.ProfilePhaseOrder() { - b.WriteString(styles.UnselectedStyle.Render(" • " + phase + "-" + profileName)) b.WriteString("\n") - } + b.WriteString(styles.WarningStyle.Render("This action cannot be undone.")) + b.WriteString("\n\n") - b.WriteString("\n") - b.WriteString(styles.WarningStyle.Render("This action cannot be undone.")) - b.WriteString("\n\n") + b.WriteString(renderOptions([]string{"Delete", "Cancel"}, cursor)) + } else { + b.WriteString(styles.SubtextStyle.Render("The following 11 agent keys will be removed from opencode.json:")) + b.WriteString("\n\n") + + // Show orchestrator key. + b.WriteString(styles.UnselectedStyle.Render(" • sdd-orchestrator-" + profileName)) + b.WriteString("\n") + + // Show phase keys using the canonical phase list from the sdd package. + for _, phase := range sdd.ProfilePhaseOrder() { + b.WriteString(styles.UnselectedStyle.Render(" • " + phase + "-" + profileName)) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(styles.WarningStyle.Render("This action cannot be undone.")) + b.WriteString("\n\n") + + b.WriteString(renderOptions([]string{"Delete & Sync", "Cancel"}, cursor)) + } - b.WriteString(renderOptions([]string{"Delete & Sync", "Cancel"}, cursor)) b.WriteString("\n") b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: confirm • esc: back")) diff --git a/internal/tui/screens/profile_delete_test.go b/internal/tui/screens/profile_delete_test.go index 76f628c06..46c11c40c 100644 --- a/internal/tui/screens/profile_delete_test.go +++ b/internal/tui/screens/profile_delete_test.go @@ -10,7 +10,7 @@ import ( // ─── RenderProfileDelete ────────────────────────────────────────────────────── func TestRenderProfileDelete_ShowsProfileName(t *testing.T) { - output := screens.RenderProfileDelete("cheap", 0) + output := screens.RenderProfileDelete("cheap", 0, false) if !strings.Contains(output, "cheap") { t.Errorf("expected profile name 'cheap' in output, got:\n%s", output) @@ -18,7 +18,7 @@ func TestRenderProfileDelete_ShowsProfileName(t *testing.T) { } func TestRenderProfileDelete_ShowsTitle(t *testing.T) { - output := screens.RenderProfileDelete("premium", 0) + output := screens.RenderProfileDelete("premium", 0, false) if !strings.Contains(output, "Delete Profile") { t.Errorf("expected title 'Delete Profile' in output, got:\n%s", output) @@ -26,7 +26,7 @@ func TestRenderProfileDelete_ShowsTitle(t *testing.T) { } func TestRenderProfileDelete_ShowsDeleteAndSync(t *testing.T) { - output := screens.RenderProfileDelete("cheap", 0) + output := screens.RenderProfileDelete("cheap", 0, false) if !strings.Contains(output, "Delete & Sync") { t.Errorf("expected 'Delete & Sync' option in output, got:\n%s", output) @@ -34,7 +34,7 @@ func TestRenderProfileDelete_ShowsDeleteAndSync(t *testing.T) { } func TestRenderProfileDelete_ShowsCancel(t *testing.T) { - output := screens.RenderProfileDelete("cheap", 1) + output := screens.RenderProfileDelete("cheap", 1, false) if !strings.Contains(output, "Cancel") { t.Errorf("expected 'Cancel' option in output, got:\n%s", output) @@ -42,7 +42,7 @@ func TestRenderProfileDelete_ShowsCancel(t *testing.T) { } func TestRenderProfileDelete_ShowsAgentKeyCount(t *testing.T) { - output := screens.RenderProfileDelete("cheap", 0) + output := screens.RenderProfileDelete("cheap", 0, false) // Should mention 11 agents that will be removed. if !strings.Contains(output, "11") { diff --git a/internal/tui/screens/profiles.go b/internal/tui/screens/profiles.go index 1ee0da329..a27bc3613 100644 --- a/internal/tui/screens/profiles.go +++ b/internal/tui/screens/profiles.go @@ -8,15 +8,24 @@ import ( "github.com/gentleman-programming/gentle-ai/internal/tui/styles" ) -// RenderProfiles renders the OpenCode SDD Profiles list screen. -// It shows all named profiles with their orchestrator model, plus Create and Back actions. +// RenderProfiles renders the SDD Profiles list screen for the given adapter. +// adapterLabel is "OpenCode" or "VS Code" — it drives the title and subtitle text. // deleteErr is displayed when non-nil (e.g. RemoveProfileAgents returned an error). -func RenderProfiles(profiles []model.Profile, cursor int, deleteErr error) string { +func RenderProfiles(profiles []model.Profile, cursor int, deleteErr error, adapterLabel string) string { var b strings.Builder - b.WriteString(styles.TitleStyle.Render("OpenCode SDD Profiles")) + title := adapterLabel + " SDD Profiles" + b.WriteString(styles.TitleStyle.Render(title)) b.WriteString("\n\n") - b.WriteString(styles.SubtextStyle.Render("Your SDD model profiles for OpenCode. Each profile creates its own orchestrator (visible with Tab).")) + + var subtitle string + switch adapterLabel { + case "VS Code": + subtitle = "Your SDD model profiles for VS Code Copilot. Each profile creates 10 .agent.md files in ~/.copilot/agents/." + default: + subtitle = "Your SDD model profiles for OpenCode. Each profile creates its own orchestrator (visible with Tab)." + } + b.WriteString(styles.SubtextStyle.Render(subtitle)) b.WriteString("\n\n") if deleteErr != nil { diff --git a/internal/tui/screens/profiles_test.go b/internal/tui/screens/profiles_test.go index a2d9925bf..d0a9dde31 100644 --- a/internal/tui/screens/profiles_test.go +++ b/internal/tui/screens/profiles_test.go @@ -25,7 +25,7 @@ func makeProfile(name string, orchProvider, orchModel string) model.Profile { func TestRenderProfiles_TitleIsPresent(t *testing.T) { profiles := []model.Profile{makeProfile("cheap", "anthropic", "claude-haiku-4")} - output := screens.RenderProfiles(profiles, 0, nil) + output := screens.RenderProfiles(profiles, 0, nil, "OpenCode") if !strings.Contains(output, "OpenCode SDD Profiles") { t.Errorf("expected title 'OpenCode SDD Profiles' in output, got:\n%s", output) @@ -37,7 +37,7 @@ func TestRenderProfiles_ShowsProfileNamesWithProviderModel(t *testing.T) { makeProfile("cheap", "anthropic", "claude-haiku-4"), makeProfile("premium", "openai", "gpt-4o"), } - output := screens.RenderProfiles(profiles, 0, nil) + output := screens.RenderProfiles(profiles, 0, nil, "OpenCode") if !strings.Contains(output, "cheap") { t.Errorf("expected 'cheap' profile name in output") @@ -55,7 +55,7 @@ func TestRenderProfiles_ShowsProfileNamesWithProviderModel(t *testing.T) { func TestRenderProfiles_ShowsCreateNewProfile(t *testing.T) { profiles := []model.Profile{} - output := screens.RenderProfiles(profiles, 0, nil) + output := screens.RenderProfiles(profiles, 0, nil, "OpenCode") if !strings.Contains(output, "Create new profile") { t.Errorf("expected 'Create new profile' action in output") @@ -64,7 +64,7 @@ func TestRenderProfiles_ShowsCreateNewProfile(t *testing.T) { func TestRenderProfiles_ShowsBackOption(t *testing.T) { profiles := []model.Profile{} - output := screens.RenderProfiles(profiles, 0, nil) + output := screens.RenderProfiles(profiles, 0, nil, "OpenCode") if !strings.Contains(output, "Back") { t.Errorf("expected 'Back' option in output") @@ -73,7 +73,7 @@ func TestRenderProfiles_ShowsBackOption(t *testing.T) { func TestRenderProfiles_ShowsKeybindingHints(t *testing.T) { profiles := []model.Profile{} - output := screens.RenderProfiles(profiles, 0, nil) + output := screens.RenderProfiles(profiles, 0, nil, "OpenCode") if !strings.Contains(output, "n: new") { t.Errorf("expected 'n: new' keybinding hint in output") @@ -89,7 +89,7 @@ func TestRenderProfiles_ShowsKeybindingHints(t *testing.T) { func TestRenderProfiles_ShowsDeleteErrorWhenNonNil(t *testing.T) { profiles := []model.Profile{makeProfile("cheap", "anthropic", "claude-haiku-4")} err := fmt.Errorf("failed to write opencode.json") - output := screens.RenderProfiles(profiles, 0, err) + output := screens.RenderProfiles(profiles, 0, err, "OpenCode") if !strings.Contains(output, "failed to write opencode.json") { t.Errorf("expected delete error message in output, got:\n%s", output) diff --git a/internal/tui/screens/sdd_duplicate_warning.go b/internal/tui/screens/sdd_duplicate_warning.go new file mode 100644 index 000000000..8fcff969e --- /dev/null +++ b/internal/tui/screens/sdd_duplicate_warning.go @@ -0,0 +1,75 @@ +package screens + +import ( + "strings" + + "github.com/gentleman-programming/gentle-ai/internal/tui/styles" +) + +// DuplicatedSDDPhases lists the SDD phases that appear in both the Copilot +// native (.agent.md) and the Claude (.md) agent registries. VS Code Copilot +// scans both formats, so when a user installs SDD multi-mode for both +// adapters these phases visually duplicate in the Agent customizations panel. +// +// sdd-init and sdd-onboard are intentionally omitted: the Claude adapter +// does not ship them as sub-agents (they are orchestrator-driven flows), so +// they never duplicate. Keep this list aligned with the Claude adapter's +// embedded agents directory. +func DuplicatedSDDPhases() []string { + return []string{ + "sdd-apply", + "sdd-archive", + "sdd-design", + "sdd-explore", + "sdd-propose", + "sdd-spec", + "sdd-tasks", + "sdd-verify", + } +} + +// SDDDuplicateAgentsWarningOptions returns the selectable options for the +// warning screen, in cursor order. +func SDDDuplicateAgentsWarningOptions() []string { + return []string{"Continue anyway", "← Back to adapter selection"} +} + +// RenderSDDDuplicateAgentsWarning renders an informational warning that +// fires when SDD multi-mode is paired with both VS Code Copilot and a +// Claude-format adapter. The user can continue (accept the duplication) +// or go back to the adapter selection. +func RenderSDDDuplicateAgentsWarning(cursor int) string { + var b strings.Builder + + b.WriteString(styles.TitleStyle.Render("Heads up: VS Code will show duplicated SDD agents")) + b.WriteString("\n\n") + + b.WriteString(styles.SubtextStyle.Render("You're installing SDD multi-mode for both VS Code Copilot and Claude Code.")) + b.WriteString("\n") + b.WriteString(styles.SubtextStyle.Render("VS Code Copilot's agent panel reads two formats in parallel:")) + b.WriteString("\n") + b.WriteString(styles.UnselectedStyle.Render(" • Copilot native: ~/.copilot/agents/*.agent.md")) + b.WriteString("\n") + b.WriteString(styles.UnselectedStyle.Render(" • Claude format: ~/.claude/agents/*.md")) + b.WriteString("\n\n") + + b.WriteString(styles.HeadingStyle.Render("These 8 sub-agents will appear twice in VS Code:")) + b.WriteString("\n") + phases := DuplicatedSDDPhases() + for _, phase := range phases { + b.WriteString(styles.UnselectedStyle.Render(" • " + phase)) + b.WriteString("\n") + } + b.WriteString("\n") + + b.WriteString(styles.SubtextStyle.Render("Each file is correct and works in its own host — no behavior difference.")) + b.WriteString("\n") + b.WriteString(styles.SubtextStyle.Render("This is purely a UI quirk of VS Code's multi-format agent scanner.")) + b.WriteString("\n\n") + + b.WriteString(renderOptions(SDDDuplicateAgentsWarningOptions(), cursor)) + b.WriteString("\n") + b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: select • esc: back")) + + return styles.FrameStyle.Render(b.String()) +} diff --git a/internal/tui/screens/vscode_model_picker.go b/internal/tui/screens/vscode_model_picker.go new file mode 100644 index 000000000..6cb708508 --- /dev/null +++ b/internal/tui/screens/vscode_model_picker.go @@ -0,0 +1,390 @@ +package screens + +import ( + "fmt" + "strings" + + vscodeagent "github.com/gentleman-programming/gentle-ai/internal/agents/vscode" + "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/opencode" + "github.com/gentleman-programming/gentle-ai/internal/tui/styles" +) + +// VSCodeModelPickerState holds navigation state for the VS Code model picker. +// The model catalog is loaded dynamically from the OpenCode models cache +// (provider "github-copilot") rather than from a hardcoded list, so the +// picker reflects whatever models GitHub Copilot currently exposes to the user. +type VSCodeModelPickerState struct { + // Mode mirrors ModelPickerMode: ModePhaseList shows phase rows, + // ModeModelSelect shows the flat list of Copilot models for a chosen phase. + Mode ModelPickerMode + + // Models is the list of github-copilot models loaded from the OpenCode + // cache (filtered to tool-call-capable models, sorted by Name). + Models []opencode.Model + + // ConfigWarning is non-empty when the OpenCode cache could not be loaded + // or when it does not contain a github-copilot provider entry. The picker + // is still shown but with a banner explaining the situation. + ConfigWarning string + + SelectedPhaseIdx int + ModelCursor int + ModelScroll int + + // AllPhasesModel tracks the last "Set all phases" assignment (display name). + AllPhasesModel string +} + +// NewVSCodeModelPickerState loads the github-copilot model catalog from the +// OpenCode models cache and returns a picker state ready for use. When the +// cache is missing or the provider entry is absent, ConfigWarning is populated +// and Models is empty — the UI surfaces this to the user. +func NewVSCodeModelPickerState(cachePath string) VSCodeModelPickerState { + providers, err := opencode.LoadModels(cachePath) + if err != nil { + return VSCodeModelPickerState{ + Mode: ModePhaseList, + ConfigWarning: fmt.Sprintf("Could not load models cache %q: %v. Run `opencode sync` to populate it.", cachePath, err), + } + } + copilot, ok := providers["github-copilot"] + if !ok { + return VSCodeModelPickerState{ + Mode: ModePhaseList, + ConfigWarning: "github-copilot provider not found in OpenCode models cache. Run `opencode sync` to fetch the Copilot model catalog.", + } + } + return VSCodeModelPickerState{ + Mode: ModePhaseList, + Models: opencode.FilterModelsForSDD(copilot), + } +} + +// VSCodeOrchestratorPhase is the assignment key for the VS Code Copilot +// SDD orchestrator model. It mirrors vscodeagent.OrchestratorPhase and +// is re-exported here so model.go can reference it without importing the +// vscode agent package directly. +const VSCodeOrchestratorPhase = vscodeagent.OrchestratorPhase + +// VSCodeModelRows returns the row labels for the VS Code model picker phase list. +// Row 0 is the orchestrator (sdd-orchestrator), row 1 is "Set all phases", +// rows 2-11 are the 10 SDD phase executors. +func VSCodeModelRows() []string { + rows := make([]string, 0, 12) + rows = append(rows, vscodeagent.OrchestratorPhase) + rows = append(rows, "Set all phases") + rows = append(rows, vscodeagent.SDDPhases()...) + return rows +} + +// vscodeModelLabel returns the display label for a single opencode.Model entry. +// Prefers Name; falls back to ID when Name is empty. +func vscodeModelLabel(m opencode.Model) string { + if m.Name != "" { + return m.Name + } + return m.ID +} + +// RenderVSCodeModelPicker renders the VS Code model picker for profile create step 1. +// It shows a phase list in ModePhaseList, or a flat model list in ModeModelSelect. +func RenderVSCodeModelPicker( + assignments map[string]model.ModelAssignment, + state VSCodeModelPickerState, + cursor int, + editMode bool, + profileName string, +) string { + switch state.Mode { + case ModeModelSelect: + return renderVSCodeModelSelect(state) + default: + return renderVSCodePhaseList(assignments, state, cursor, editMode, profileName) + } +} + +func renderVSCodePhaseList( + assignments map[string]model.ModelAssignment, + state VSCodeModelPickerState, + cursor int, + editMode bool, + profileName string, +) string { + var b strings.Builder + + header := "Create VS Code SDD Profile" + if editMode { + header = "Edit VS Code SDD Profile" + } + b.WriteString(styles.TitleStyle.Render(header)) + b.WriteString("\n\n") + b.WriteString(styles.HeadingStyle.Render("Assign Models")) + b.WriteString("\n") + b.WriteString(styles.SubtextStyle.Render("Assign Copilot models for profile: " + profileName)) + b.WriteString("\n\n") + + if state.ConfigWarning != "" { + b.WriteString(styles.WarningStyle.Render(state.ConfigWarning)) + b.WriteString("\n\n") + } + + rows := VSCodeModelRows() + phases := vscodeagent.SDDPhases() + + for idx, row := range rows { + focused := idx == cursor + + var label string + switch { + case idx == 0: + // Row 0: sdd-orchestrator — individual assignment. + if assignment, ok := assignments[vscodeagent.OrchestratorPhase]; ok && assignment.ModelID != "" { + label = fmt.Sprintf("%-22s %s", row, assignment.ModelID) + } else { + label = fmt.Sprintf("%-22s (default)", row) + } + case idx == 1: + // Row 1: "Set all phases" — shows last bulk-set model. + if state.AllPhasesModel != "" { + label = fmt.Sprintf("%-22s (%s)", row, state.AllPhasesModel) + } else { + label = fmt.Sprintf("%-22s (not set)", row) + } + default: + // Rows 2-11: individual SDD phases. + phaseIdx := idx - 2 + if phaseIdx < len(phases) { + phase := phases[phaseIdx] + if assignment, ok := assignments[phase]; ok && assignment.ModelID != "" { + label = fmt.Sprintf("%-22s %s", row, assignment.ModelID) + } else { + label = fmt.Sprintf("%-22s (default)", row) + } + } + } + + if focused { + b.WriteString(styles.SelectedStyle.Render(styles.Cursor+label) + "\n") + } else { + b.WriteString(styles.UnselectedStyle.Render(" "+label) + "\n") + } + } + + b.WriteString("\n") + actionIdx := cursor - len(rows) + b.WriteString(renderOptions([]string{"Continue", "← Back"}, actionIdx)) + b.WriteString("\n") + b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: change model / confirm • esc: back")) + + return styles.FrameStyle.Render(b.String()) +} + +func renderVSCodeModelSelect(state VSCodeModelPickerState) string { + var b strings.Builder + + b.WriteString(styles.TitleStyle.Render("Select Copilot model:")) + b.WriteString("\n\n") + + if len(state.Models) == 0 { + if state.ConfigWarning != "" { + b.WriteString(styles.WarningStyle.Render(state.ConfigWarning)) + } else { + b.WriteString(styles.SubtextStyle.Render("No Copilot models available.")) + } + b.WriteString("\n\n") + b.WriteString(styles.HelpStyle.Render("esc: back")) + return b.String() + } + + end := state.ModelScroll + maxVisibleItems + if end > len(state.Models) { + end = len(state.Models) + } + + if state.ModelScroll > 0 { + b.WriteString(styles.SubtextStyle.Render(" ↑ more")) + b.WriteString("\n") + } + + for i := state.ModelScroll; i < end; i++ { + label := vscodeModelLabel(state.Models[i]) + focused := i == state.ModelCursor + if focused { + b.WriteString(styles.SelectedStyle.Render(styles.Cursor+label) + "\n") + } else { + b.WriteString(styles.UnselectedStyle.Render(" "+label) + "\n") + } + } + + if end < len(state.Models) { + b.WriteString(styles.SubtextStyle.Render(" ↓ more")) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: select • esc: back")) + + return b.String() +} + +// HandleVSCodeModelPickerNav handles key navigation for the VS Code model picker. +// Returns true when handled (caller should skip default nav). +func HandleVSCodeModelPickerNav( + key string, + state *VSCodeModelPickerState, + assignments map[string]model.ModelAssignment, +) (handled bool, updated map[string]model.ModelAssignment) { + if assignments == nil { + assignments = make(map[string]model.ModelAssignment) + } + + if state.Mode != ModeModelSelect { + return false, assignments + } + + phases := vscodeagent.SDDPhases() + + switch key { + case "up", "k": + if state.ModelCursor > 0 { + state.ModelCursor-- + if state.ModelCursor < state.ModelScroll { + state.ModelScroll = state.ModelCursor + } + } + return true, assignments + case "down", "j": + if state.ModelCursor < len(state.Models)-1 { + state.ModelCursor++ + if state.ModelCursor >= state.ModelScroll+maxVisibleItems { + state.ModelScroll = state.ModelCursor - maxVisibleItems + 1 + } + } + return true, assignments + case "enter": + if len(state.Models) == 0 { + return true, assignments + } + entry := state.Models[state.ModelCursor] + assignment := model.ModelAssignment{ + ProviderID: "github-copilot", + ModelID: entry.ID, + } + label := vscodeModelLabel(entry) + switch state.SelectedPhaseIdx { + case 0: + // Row 0: sdd-orchestrator — assign only to the orchestrator key. + assignments[vscodeagent.OrchestratorPhase] = assignment + case 1: + // Row 1: "Set all phases" — sets the 10 sub-agents, NOT the orchestrator. + for _, phase := range phases { + assignments[phase] = assignment + } + state.AllPhasesModel = label + default: + // Rows 2-11: individual SDD phases. + phaseIdx := state.SelectedPhaseIdx - 2 + if phaseIdx < len(phases) { + assignments[phases[phaseIdx]] = assignment + } + } + state.Mode = ModePhaseList + state.ModelCursor = 0 + state.ModelScroll = 0 + return true, assignments + case "esc": + state.Mode = ModePhaseList + state.ModelCursor = 0 + state.ModelScroll = 0 + return true, assignments + } + + return false, assignments +} + +// VSCodeModelPickerOptionCount returns the option count for the VS Code phase list. +// Rows (1 orchestrator + 1 "Set all" + 10 phases) + Continue + Back = 14. +func VSCodeModelPickerOptionCount() int { + return len(VSCodeModelRows()) + 2 +} + +// RenderVSCodeProfileCreate renders the multi-step profile create/edit screen for VS Code. +// Step 0: name input (identical to OpenCode) +// Step 1: VS Code model picker (Copilot-only catalog from cache) +// Step 2: confirm +func RenderVSCodeProfileCreate( + step int, + draft model.Profile, + nameInput string, + namePos int, + nameErr string, + editMode bool, + assignments map[string]model.ModelAssignment, + picker VSCodeModelPickerState, + cursor int, +) string { + switch step { + case 0: + return RenderProfileCreate(step, draft, nameInput, namePos, nameErr, editMode, nil, ModelPickerState{}, cursor) + case 1: + return RenderVSCodeModelPicker(assignments, picker, cursor, editMode, draft.Name) + default: + return renderVSCodeProfileConfirmStep(draft, cursor, editMode) + } +} + +// renderVSCodeProfileConfirmStep renders the VS Code confirm step. +func renderVSCodeProfileConfirmStep(draft model.Profile, cursor int, editMode bool) string { + var b strings.Builder + + header := "Create VS Code SDD Profile" + if editMode { + header = "Edit VS Code SDD Profile" + } + b.WriteString(styles.TitleStyle.Render(header)) + b.WriteString("\n\n") + b.WriteString(styles.HeadingStyle.Render("Profile Summary")) + b.WriteString("\n\n") + + b.WriteString(styles.SubtextStyle.Render("Name: ")) + b.WriteString(styles.SelectedStyle.Render(draft.Name)) + b.WriteString("\n") + + phaseCount := len(draft.PhaseAssignments) + if phaseCount > 0 { + b.WriteString(styles.SubtextStyle.Render("Phase assignments: ")) + b.WriteString(styles.UnselectedStyle.Render(fmt.Sprintf("%d assigned", phaseCount))) + b.WriteString("\n") + } else { + b.WriteString(styles.SubtextStyle.Render("Phase assignments: ")) + b.WriteString(styles.UnselectedStyle.Render("(default Copilot model)")) + b.WriteString("\n") + } + + b.WriteString(styles.SubtextStyle.Render("Files to write: ")) + b.WriteString(styles.UnselectedStyle.Render("10 .agent.md files in ~/.copilot/agents/")) + b.WriteString("\n\n") + + confirmLabel := "Create" + if editMode { + confirmLabel = "Save" + } + b.WriteString(renderOptions([]string{confirmLabel, "Cancel"}, cursor)) + b.WriteString("\n") + b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: confirm • esc: back")) + + return styles.FrameStyle.Render(b.String()) +} + +// VSCodeProfileCreateOptionCount returns the number of selectable options for a given step. +func VSCodeProfileCreateOptionCount(step int) int { + switch step { + case 0: + return 0 + case 1: + return VSCodeModelPickerOptionCount() + default: + return 2 + } +} diff --git a/internal/tui/screens/vscode_model_picker_test.go b/internal/tui/screens/vscode_model_picker_test.go new file mode 100644 index 000000000..cb0826088 --- /dev/null +++ b/internal/tui/screens/vscode_model_picker_test.go @@ -0,0 +1,169 @@ +package screens + +import ( + "testing" + + "github.com/gentleman-programming/gentle-ai/internal/model" + "github.com/gentleman-programming/gentle-ai/internal/opencode" +) + +// ── VSCodeModelRows ────────────────────────────────────────────────────────── + +func TestVSCodeModelRows_Count(t *testing.T) { + // 1 orchestrator + 1 "Set all phases" + 10 SDD phases = 12 rows. + rows := VSCodeModelRows() + if len(rows) != 12 { + t.Fatalf("VSCodeModelRows() len = %d, want 12", len(rows)) + } +} + +func TestVSCodeModelRows_OrchestratorIsFirst(t *testing.T) { + rows := VSCodeModelRows() + if rows[0] != VSCodeOrchestratorPhase { + t.Fatalf("VSCodeModelRows()[0] = %q, want %q", rows[0], VSCodeOrchestratorPhase) + } +} + +func TestVSCodeModelRows_SetAllPhasesIsSecond(t *testing.T) { + rows := VSCodeModelRows() + if rows[1] != "Set all phases" { + t.Fatalf("VSCodeModelRows()[1] = %q, want %q", rows[1], "Set all phases") + } +} + +func TestVSCodeModelRows_PhaseRowsStart_AtIndex2(t *testing.T) { + rows := VSCodeModelRows() + // rows[2] must be a real SDD phase, not orchestrator or Set-all. + if rows[2] == VSCodeOrchestratorPhase || rows[2] == "Set all phases" { + t.Fatalf("VSCodeModelRows()[2] = %q, expected a real SDD phase", rows[2]) + } +} + +// ── VSCodeOrchestratorPhase constant ──────────────────────────────────────── + +func TestVSCodeOrchestratorPhase_Value(t *testing.T) { + if VSCodeOrchestratorPhase != "sdd-orchestrator" { + t.Fatalf("VSCodeOrchestratorPhase = %q, want %q", VSCodeOrchestratorPhase, "sdd-orchestrator") + } +} + +// ── VSCodeModelPickerOptionCount ───────────────────────────────────────────── + +func TestVSCodeModelPickerOptionCount(t *testing.T) { + // 12 rows + Continue + Back = 14. + got := VSCodeModelPickerOptionCount() + if got != 14 { + t.Fatalf("VSCodeModelPickerOptionCount() = %d, want 14", got) + } +} + +// ── HandleVSCodeModelPickerNav ─────────────────────────────────────────────── + +func makeVSCodeTestState(selectedPhaseIdx int) VSCodeModelPickerState { + return VSCodeModelPickerState{ + Mode: ModeModelSelect, + SelectedPhaseIdx: selectedPhaseIdx, + Models: []opencode.Model{ + {ID: "claude-sonnet-4-20250514", Name: "Claude Sonnet 4"}, + }, + } +} + +// Row 0 (orchestrator) must assign only to the orchestrator key. +func TestHandleVSCodeModelNav_OrchestratorRow_AssignsOnlyOrchestrator(t *testing.T) { + state := VSCodeModelPickerState{ + Mode: ModeModelSelect, + SelectedPhaseIdx: 0, // orchestrator row + Models: []opencode.Model{{ID: "claude-sonnet-4-20250514", Name: "Claude Sonnet 4"}}, + } + assignments := map[string]model.ModelAssignment{} + + handled, updated := HandleVSCodeModelPickerNav("enter", &state, assignments) + if !handled { + t.Fatal("expected handled=true") + } + + // Orchestrator key must be set. + orch, ok := updated[VSCodeOrchestratorPhase] + if !ok { + t.Fatalf("expected %q to be assigned; assignments: %v", VSCodeOrchestratorPhase, updated) + } + if orch.ModelID != "claude-sonnet-4-20250514" { + t.Errorf("orchestrator ModelID = %q, want %q", orch.ModelID, "claude-sonnet-4-20250514") + } + + // No SDD phase must be assigned. + rows := VSCodeModelRows() + for _, phase := range rows[2:] { + if _, exists := updated[phase]; exists { + t.Errorf("phase %q should NOT be assigned when selecting orchestrator row; assignments: %v", phase, updated) + } + } +} + +// Row 1 ("Set all phases") must assign to 10 sub-agents but NOT the orchestrator. +func TestHandleVSCodeModelNav_SetAllPhasesRow_AssignsPhasesNotOrchestrator(t *testing.T) { + state := VSCodeModelPickerState{ + Mode: ModeModelSelect, + SelectedPhaseIdx: 1, // "Set all phases" row + Models: []opencode.Model{{ID: "gpt-4o", Name: "GPT-4o"}}, + } + assignments := map[string]model.ModelAssignment{} + + _, updated := HandleVSCodeModelPickerNav("enter", &state, assignments) + + // Orchestrator must NOT be set. + if _, exists := updated[VSCodeOrchestratorPhase]; exists { + t.Errorf("orchestrator should NOT be assigned by 'Set all phases'; assignments: %v", updated) + } + + // All 10 SDD phases must be set. + rows := VSCodeModelRows() + for _, phase := range rows[2:] { + if a, ok := updated[phase]; !ok || a.ModelID != "gpt-4o" { + t.Errorf("phase %q: ModelID = %q, want %q", phase, a.ModelID, "gpt-4o") + } + } +} + +// Row 1 "Set all phases" must NOT overwrite a pre-existing orchestrator assignment. +func TestHandleVSCodeModelNav_SetAllPhasesRow_DoesNotOverwriteExistingOrchestrator(t *testing.T) { + existing := model.ModelAssignment{ProviderID: "github-copilot", ModelID: "claude-sonnet-4-20250514"} + state := VSCodeModelPickerState{ + Mode: ModeModelSelect, + SelectedPhaseIdx: 1, // "Set all phases" + Models: []opencode.Model{{ID: "gpt-4o", Name: "GPT-4o"}}, + } + assignments := map[string]model.ModelAssignment{ + VSCodeOrchestratorPhase: existing, + } + + _, updated := HandleVSCodeModelPickerNav("enter", &state, assignments) + + orch := updated[VSCodeOrchestratorPhase] + if orch != existing { + t.Errorf("orchestrator assignment should be unchanged; got: %v", orch) + } +} + +// Row 2 (first SDD phase) must assign only to that phase. +func TestHandleVSCodeModelNav_PhaseRow_AssignsOnlyThatPhase(t *testing.T) { + state := VSCodeModelPickerState{ + Mode: ModeModelSelect, + SelectedPhaseIdx: 2, // first SDD phase (sdd-init) + Models: []opencode.Model{{ID: "gemini-2.5-pro", Name: "Gemini 2.5 Pro"}}, + } + assignments := map[string]model.ModelAssignment{} + + _, updated := HandleVSCodeModelPickerNav("enter", &state, assignments) + + // Orchestrator must not be touched. + if _, exists := updated[VSCodeOrchestratorPhase]; exists { + t.Errorf("orchestrator should not be assigned; assignments: %v", updated) + } + + // Exactly one phase must be assigned. + if len(updated) != 1 { + t.Errorf("expected 1 assigned phase, got %d; assignments: %v", len(updated), updated) + } +} diff --git a/internal/tui/screens/welcome.go b/internal/tui/screens/welcome.go index 007f01027..1e4cdec11 100644 --- a/internal/tui/screens/welcome.go +++ b/internal/tui/screens/welcome.go @@ -10,11 +10,11 @@ import ( // WelcomeOptions returns the welcome menu options. // When showProfiles is true, an "OpenCode SDD Profiles" option is inserted -// between "Configure models" and "Manage backups". -// profileCount is used to show a badge with the current profile count. -// When hasEngines is false, "Create your own Agent" is shown as disabled -// (labelled "(no agents)") to signal that no supported AI engine is installed. -func WelcomeOptions(updateResults []update.UpdateResult, updateCheckDone bool, showProfiles bool, profileCount int, hasEngines bool) []string { +// after the plugins entry. profileCount is used to show a badge. +// When showVSCodeProfiles is true, a "VS Code SDD Profiles" option is inserted +// right after the OpenCode profiles entry. vscodeProfileCount is its badge. +// When hasEngines is false, "Create your own Agent" is shown as disabled. +func WelcomeOptions(updateResults []update.UpdateResult, updateCheckDone bool, showProfiles bool, profileCount int, hasEngines bool, showVSCodeProfiles bool, vscodeProfileCount int) []string { upgradeLabel := "Upgrade tools" if updateCheckDone && update.HasUpdates(updateResults) { upgradeLabel = "Upgrade tools ★" @@ -45,6 +45,14 @@ func WelcomeOptions(updateResults []update.UpdateResult, updateCheckDone bool, s opts = append(opts, profilesLabel) } + if showVSCodeProfiles { + vscLabel := "VS Code SDD Profiles" + if vscodeProfileCount > 0 { + vscLabel = fmt.Sprintf("VS Code SDD Profiles (%d)", vscodeProfileCount) + } + opts = append(opts, vscLabel) + } + opts = append(opts, "Manage backups") opts = append(opts, "Managed uninstall") opts = append(opts, "Quit") @@ -52,7 +60,7 @@ func WelcomeOptions(updateResults []update.UpdateResult, updateCheckDone bool, s return opts } -func RenderWelcome(cursor int, version string, updateBanner string, updateResults []update.UpdateResult, updateCheckDone bool, showProfiles bool, profileCount int, hasEngines bool) string { +func RenderWelcome(cursor int, version string, updateBanner string, updateResults []update.UpdateResult, updateCheckDone bool, showProfiles bool, profileCount int, hasEngines bool, showVSCodeProfiles bool, vscodeProfileCount int) string { var b strings.Builder b.WriteString(styles.RenderLogo()) @@ -68,7 +76,7 @@ func RenderWelcome(cursor int, version string, updateBanner string, updateResult b.WriteString("\n") b.WriteString(styles.HeadingStyle.Render("Menu")) b.WriteString("\n\n") - b.WriteString(renderOptions(WelcomeOptions(updateResults, updateCheckDone, showProfiles, profileCount, hasEngines), cursor)) + b.WriteString(renderOptions(WelcomeOptions(updateResults, updateCheckDone, showProfiles, profileCount, hasEngines, showVSCodeProfiles, vscodeProfileCount), cursor)) b.WriteString("\n") b.WriteString(styles.HelpStyle.Render("j/k: navigate • enter: select • q: quit")) diff --git a/internal/tui/screens/welcome_test.go b/internal/tui/screens/welcome_test.go index ab52911f3..d2b657b15 100644 --- a/internal/tui/screens/welcome_test.go +++ b/internal/tui/screens/welcome_test.go @@ -12,7 +12,7 @@ import ( // TestWelcomeOptions_WithoutProfiles verifies that when showProfiles is false, // the "OpenCode SDD Profiles" option is NOT present. func TestWelcomeOptions_WithoutProfiles(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, false, 0, true) + opts := screens.WelcomeOptions(nil, true, false, 0, true, false, 0) if !containsOption(opts, "OpenCode Community Plugins") { t.Fatalf("expected dedicated OpenCode Community Plugins option; got: %v", opts) } @@ -26,7 +26,7 @@ func TestWelcomeOptions_WithoutProfiles(t *testing.T) { // TestWelcomeOptions_WithProfiles_ZeroCount shows "OpenCode SDD Profiles" without a badge. func TestWelcomeOptions_WithProfiles_ZeroCount(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, true, 0, true) + opts := screens.WelcomeOptions(nil, true, true, 0, true, false, 0) found := false for _, opt := range opts { if opt == "OpenCode SDD Profiles" { @@ -43,7 +43,7 @@ func TestWelcomeOptions_WithProfiles_ZeroCount(t *testing.T) { // TestWelcomeOptions_WithProfiles_CountTwo shows "OpenCode SDD Profiles (2)". func TestWelcomeOptions_WithProfiles_CountTwo(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, true, 2, true) + opts := screens.WelcomeOptions(nil, true, true, 2, true, false, 0) found := false for _, opt := range opts { if opt == "OpenCode SDD Profiles (2)" { @@ -57,7 +57,7 @@ func TestWelcomeOptions_WithProfiles_CountTwo(t *testing.T) { // TestWelcomeOptions_WithProfiles_CountOne shows "OpenCode SDD Profiles (1)". func TestWelcomeOptions_WithProfiles_CountOne(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, true, 1, true) + opts := screens.WelcomeOptions(nil, true, true, 1, true, false, 0) found := false for _, opt := range opts { if opt == "OpenCode SDD Profiles (1)" { @@ -72,7 +72,7 @@ func TestWelcomeOptions_WithProfiles_CountOne(t *testing.T) { // TestWelcomeOptions_OptionCount_WithoutProfiles verifies 9 options when showProfiles=false // and hasEngines=true (agent option visible). func TestWelcomeOptions_OptionCount_WithoutProfiles(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, false, 0, true) + opts := screens.WelcomeOptions(nil, true, false, 0, true, false, 0) // Expected: Start installation, Upgrade tools, Sync configs, Upgrade + Sync, // Configure models, Create your own Agent, OpenCode Community Plugins, Manage backups, Managed uninstall, Quit = 10 want := 10 @@ -84,7 +84,7 @@ func TestWelcomeOptions_OptionCount_WithoutProfiles(t *testing.T) { // TestWelcomeOptions_OptionCount_WithProfiles verifies 10 options when showProfiles=true // and hasEngines=true. func TestWelcomeOptions_OptionCount_WithProfiles(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, true, 2, true) + opts := screens.WelcomeOptions(nil, true, true, 2, true, false, 0) // Expected: Start installation, Upgrade tools, Sync configs, Upgrade + Sync, // Configure models, Create your own Agent, OpenCode Community Plugins, OpenCode SDD Profiles (2), Manage backups, Managed uninstall, Quit = 11 want := 11 @@ -96,7 +96,7 @@ func TestWelcomeOptions_OptionCount_WithProfiles(t *testing.T) { // TestWelcomeOptions_NoEngines_ShowsDisabledLabel verifies that when hasEngines=false, // the agent option is labelled "(no agents)" to signal unavailability. func TestWelcomeOptions_NoEngines_ShowsDisabledLabel(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, false, 0, false) + opts := screens.WelcomeOptions(nil, true, false, 0, false, false, 0) found := false for _, opt := range opts { if strings.Contains(opt, "no agents") { @@ -111,7 +111,7 @@ func TestWelcomeOptions_NoEngines_ShowsDisabledLabel(t *testing.T) { // TestWelcomeOptions_ProfilesInsertedBeforeManageBackups verifies the ordering: // profiles option sits between "Create your own Agent" and "Manage backups". func TestWelcomeOptions_ProfilesInsertedBeforeManageBackups(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, true, 1, true) + opts := screens.WelcomeOptions(nil, true, true, 1, true, false, 0) agentIdx := -1 pluginsIdx := -1 @@ -169,7 +169,7 @@ func containsOption(opts []string, want string) bool { } func TestWelcomeOptions_IncludesManagedUninstall(t *testing.T) { - opts := screens.WelcomeOptions(nil, true, false, 0, true) + opts := screens.WelcomeOptions(nil, true, false, 0, true, false, 0) found := false for _, opt := range opts { @@ -188,7 +188,7 @@ func TestWelcomeOptions_IncludesManagedUninstall(t *testing.T) { // TestRenderWelcome_WithoutProfiles verifies no "OpenCode SDD Profiles" in output. func TestRenderWelcome_WithoutProfiles(t *testing.T) { - output := screens.RenderWelcome(0, "1.0.0", "", nil, true, false, 0, true) + output := screens.RenderWelcome(0, "1.0.0", "", nil, true, false, 0, true, false, 0) if strings.Contains(output, "OpenCode SDD Profiles") { snippet := output if len(snippet) > 200 { @@ -200,7 +200,7 @@ func TestRenderWelcome_WithoutProfiles(t *testing.T) { // TestRenderWelcome_WithProfiles_ZeroCount contains "OpenCode SDD Profiles" but no badge. func TestRenderWelcome_WithProfiles_ZeroCount(t *testing.T) { - output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 0, true) + output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 0, true, false, 0) if !strings.Contains(output, "OpenCode SDD Profiles") { t.Errorf("RenderWelcome(showProfiles=true, count=0) missing 'OpenCode SDD Profiles'") } @@ -211,7 +211,7 @@ func TestRenderWelcome_WithProfiles_ZeroCount(t *testing.T) { // TestRenderWelcome_WithProfiles_CountTwo contains "OpenCode SDD Profiles (2)". func TestRenderWelcome_WithProfiles_CountTwo(t *testing.T) { - output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 2, true) + output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 2, true, false, 0) if !strings.Contains(output, "OpenCode SDD Profiles (2)") { t.Errorf("RenderWelcome(showProfiles=true, count=2) missing 'OpenCode SDD Profiles (2)'") } @@ -219,7 +219,7 @@ func TestRenderWelcome_WithProfiles_CountTwo(t *testing.T) { // TestRenderWelcome_WithProfiles_CountOne contains "OpenCode SDD Profiles (1)". func TestRenderWelcome_WithProfiles_CountOne(t *testing.T) { - output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 1, true) + output := screens.RenderWelcome(0, "1.0.0", "", nil, true, true, 1, true, false, 0) if !strings.Contains(output, "OpenCode SDD Profiles (1)") { t.Errorf("RenderWelcome(showProfiles=true, count=1) missing 'OpenCode SDD Profiles (1)'") }