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..c57c1b29c --- /dev/null +++ b/internal/agents/vscode/vscode_profiles.go @@ -0,0 +1,305 @@ +package vscode + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/gentleman-programming/gentle-ai/internal/assets" + "github.com/gentleman-programming/gentle-ai/internal/components/filemerge" + "github.com/gentleman-programming/gentle-ai/internal/model" +) + +// vscModelEntries maps model ID substrings to VS Code Copilot display names. +// Unknown models fall back to "ProviderID/ModelID" for traceability. +// Empty model ID means no model field — Copilot uses its default. +// Entries are checked in order; longer/more-specific substrings must come first +// to avoid partial matches. Critical pairs today: +// - "gpt-4o-mini" before "gpt-4o" +// - "gpt-4.1-mini" before "gpt-4.1" +// +// Future risk: when a new Claude Sonnet (e.g. "claude-sonnet-4-5") or any other +// versioned successor lands, place the more specific entry BEFORE the broader +// "claude-sonnet-4" entry — otherwise the broader substring wins. +var vscModelEntries = []struct { + substr string + display string +}{ + {"claude-sonnet-4", "Claude Sonnet 4 (copilot)"}, + {"claude-opus-4-5", "Claude Opus 4.5 (copilot)"}, + {"claude-haiku-4-5", "Claude Haiku 4.5 (copilot)"}, + {"gemini-2.5-pro", "Gemini 2.5 Pro (copilot)"}, + {"gemini-2.5-flash", "Gemini 2.5 Flash (copilot)"}, + {"gpt-4.1-mini", "GPT 4.1 Mini (copilot)"}, + {"gpt-4o-mini", "GPT 4o Mini (copilot)"}, + {"gpt-4.1", "GPT 4.1 (copilot)"}, + {"gpt-4o", "GPT 4o (copilot)"}, +} + +// VSCodeModelID maps a ModelAssignment (provider/model) to a VS Code Copilot +// display name. Known models get friendly display names; unknown models get +// the full ProviderID/ModelID as a fallback. Empty ModelID returns empty string +// (meaning: omit the model field entirely — Copilot uses its default). +func VSCodeModelID(m model.ModelAssignment) string { + if m.ModelID == "" { + return "" + } + for _, entry := range vscModelEntries { + if strings.Contains(m.ModelID, entry.substr) { + return entry.display + } + } + // Fallback: use full qualified ID for traceability + return m.ProviderID + "/" + m.ModelID +} + +// SDD phases in canonical order (excludes orchestrator, which is handled specially). +var sddPhases = []string{ + "sdd-init", + "sdd-explore", + "sdd-propose", + "sdd-spec", + "sdd-design", + "sdd-tasks", + "sdd-apply", + "sdd-verify", + "sdd-archive", + "sdd-onboard", +} + +// sddPhaseDescriptions provides short descriptions for each SDD phase agent. +var sddPhaseDescriptions = map[string]string{ + "sdd-init": "Initialize SDD context for the project", + "sdd-explore": "Investigate ideas and approaches before committing to a change", + "sdd-propose": "Draft a change proposal with intent and scope", + "sdd-spec": "Write requirements and acceptance scenarios", + "sdd-design": "Write architecture and file-change design", + "sdd-tasks": "Break down a change into implementation task checklist", + "sdd-apply": "Implement code changes from task definitions", + "sdd-verify": "Validate implementation against specs and design", + "sdd-archive": "Sync delta specs and archive completed change", + "sdd-onboard": "Guided end-to-end SDD walkthrough", +} + +// 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 +} + +// ReadVSCodeAgentTemplate reads an embedded .agent.md template by phase name. +func ReadVSCodeAgentTemplate(phase string) (string, error) { + return assets.Read("vscode/agents/" + phase + ".agent.md") +} + +// ListVSCodeAgentTemplates returns the list of embedded VS Code agent template files. +func ListVSCodeAgentTemplates() ([]string, error) { + entries, err := fs.ReadDir(assets.FS, "vscode/agents") + if err != nil { + return nil, fmt.Errorf("read embedded vscode/agents dir: %w", err) + } + var names []string + for _, entry := range entries { + if !entry.IsDir() { + names = append(names, entry.Name()) + } + } + return names, nil +} \ No newline at end of file diff --git a/internal/agents/vscode/vscode_profiles_test.go b/internal/agents/vscode/vscode_profiles_test.go new file mode 100644 index 000000000..bb2778e10 --- /dev/null +++ b/internal/agents/vscode/vscode_profiles_test.go @@ -0,0 +1,286 @@ +package vscode + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gentleman-programming/gentle-ai/internal/model" +) + +func TestVSCodeModelID_KnownProviders(t *testing.T) { + tests := []struct { + name string + provider string + modelID string + want string + }{ + { + name: "anthropic claude-sonnet-4", + provider: "anthropic", + modelID: "claude-sonnet-4-20250514", + want: "Claude Sonnet 4 (copilot)", + }, + { + name: "anthropic claude-opus-4-5", + provider: "anthropic", + modelID: "claude-opus-4-5-20250514", + want: "Claude Opus 4.5 (copilot)", + }, + { + name: "anthropic claude-haiku-4-5", + provider: "anthropic", + modelID: "claude-haiku-4-5-20250514", + want: "Claude Haiku 4.5 (copilot)", + }, + { + name: "openai gpt-4o", + provider: "openai", + modelID: "gpt-4o-2024-11-20", + want: "GPT 4o (copilot)", + }, + { + name: "openai gpt-4o-mini", + provider: "openai", + modelID: "gpt-4o-mini", + want: "GPT 4o Mini (copilot)", + }, + { + name: "openai gpt-4.1", + provider: "openai", + modelID: "gpt-4.1-2025-04-14", + want: "GPT 4.1 (copilot)", + }, + { + name: "openai gpt-4.1-mini", + provider: "openai", + modelID: "gpt-4.1-mini", + want: "GPT 4.1 Mini (copilot)", + }, + { + name: "google gemini-2.5-pro", + provider: "google", + modelID: "gemini-2.5-pro-preview-05-06", + want: "Gemini 2.5 Pro (copilot)", + }, + { + name: "google gemini-2.5-flash", + provider: "google", + modelID: "gemini-2.5-flash-preview-05-20", + want: "Gemini 2.5 Flash (copilot)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := VSCodeModelID(model.ModelAssignment{ProviderID: tt.provider, ModelID: tt.modelID}) + if got != tt.want { + t.Fatalf("VSCodeModelID({%s, %s}) = %q, want %q", tt.provider, tt.modelID, got, tt.want) + } + }) + } +} + +func TestVSCodeModelID_UnknownProvider_FallsBack(t *testing.T) { + got := VSCodeModelID(model.ModelAssignment{ProviderID: "unknown", ModelID: "cheap-model"}) + if got != "unknown/cheap-model" { + t.Fatalf("VSCodeModelID({unknown, cheap-model}) = %q, want %q", got, "unknown/cheap-model") + } +} + +func TestVSCodeModelID_EmptyModelID_ReturnsEmpty(t *testing.T) { + got := VSCodeModelID(model.ModelAssignment{ProviderID: "anthropic", ModelID: ""}) + if got != "" { + t.Fatalf("VSCodeModelID({anthropic, ''}) = %q, want empty string", got) + } +} + +func TestGenerateAgentFile_DefaultProfile(t *testing.T) { + profile := model.Profile{ + Name: "", + PhaseAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "anthropic", ModelID: "claude-sonnet-4-20250514"}, + }, + } + + content := GenerateAgentFile("sdd-apply", profile) + + // Must have YAML frontmatter markers + if !strings.HasPrefix(content, "---\n") { + t.Fatalf("Agent file must start with YAML frontmatter, got: %q", content[:min(50, len(content))]) + } + if !strings.Contains(content, "\n---\n") { + t.Fatalf("Agent file must have closing YAML frontmatter marker") + } + + // Must contain required fields + if !strings.Contains(content, "name: sdd-apply") { + t.Fatalf("Agent file must contain 'name: sdd-apply', got:\n%s", content) + } + if !strings.Contains(content, "description:") { + t.Fatalf("Agent file must contain 'description:' field") + } + if !strings.Contains(content, "readonly:") { + t.Fatalf("Agent file must contain 'readonly:' field") + } + if !strings.Contains(content, "background:") { + t.Fatalf("Agent file must contain 'background:' field") + } + if !strings.Contains(content, "user-invocable:") { + t.Fatalf("Agent file must contain 'user-invocable:' field") + } + + // Must have model mapping for known provider + if !strings.Contains(content, "model: \"Claude Sonnet 4 (copilot)\"") { + t.Fatalf("Agent file must contain resolved model name, got:\n%s", content) + } +} + +func TestGenerateAgentFile_NamedProfile(t *testing.T) { + profile := model.Profile{ + Name: "cheap", + PhaseAssignments: map[string]model.ModelAssignment{ + "sdd-apply": {ProviderID: "openai", ModelID: "gpt-4o-mini"}, + }, + } + + content := GenerateAgentFile("sdd-apply", profile) + + // Named profile should include profile name in the agent name + if !strings.Contains(content, "name: sdd-apply-cheap") { + t.Fatalf("Named profile agent must have suffixed name, got:\n%s", content) + } +} + +func TestGenerateAgentFile_NoModelAssignment_OmitsField(t *testing.T) { + profile := model.Profile{ + Name: "", + PhaseAssignments: map[string]model.ModelAssignment{}, + } + + content := GenerateAgentFile("sdd-apply", profile) + + // When no model assignment for the phase, model field must be absent or omitted + frontmatterEnd := strings.Index(content[4:], "\n---\n") + if frontmatterEnd == -1 { + t.Fatalf("Cannot find closing frontmatter marker") + } + frontmatter := content[4 : frontmatterEnd+4] + + if strings.Contains(frontmatter, "model:") { + t.Fatalf("When no model assignment, model field must be absent from frontmatter, got:\n%s", frontmatter) + } +} + +func TestVSCodeModelID_AllDesignMappings(t *testing.T) { + // Verify every mapping from the design doc + designMappings := []struct { + modelSubstr string + expected string + }{ + {"claude-sonnet-4", "Claude Sonnet 4 (copilot)"}, + {"claude-opus-4-5", "Claude Opus 4.5 (copilot)"}, + {"claude-haiku-4-5", "Claude Haiku 4.5 (copilot)"}, + {"gemini-2.5-pro", "Gemini 2.5 Pro (copilot)"}, + {"gemini-2.5-flash", "Gemini 2.5 Flash (copilot)"}, + {"gpt-4.1", "GPT 4.1 (copilot)"}, + {"gpt-4.1-mini", "GPT 4.1 Mini (copilot)"}, + {"gpt-4o", "GPT 4o (copilot)"}, + {"gpt-4o-mini", "GPT 4o Mini (copilot)"}, + } + + for _, dm := range designMappings { + t.Run(dm.modelSubstr, func(t *testing.T) { + got := VSCodeModelID(model.ModelAssignment{ProviderID: "any", ModelID: dm.modelSubstr}) + if got != dm.expected { + t.Fatalf("VSCodeModelID({any, %s}) = %q, want %q", dm.modelSubstr, got, dm.expected) + } + }) + } +} + +func TestRemoveVSCodeProfileAgents_RemovesOnlyGentleAIAssets(t *testing.T) { + agentsDir := t.TempDir() + + // Create mixed files: gentle-ai SDD files + user files + gentleAISDD := []string{ + "sdd-init-cheap.agent.md", + "sdd-explore-cheap.agent.md", + "sdd-propose-cheap.agent.md", + "sdd-spec-cheap.agent.md", + "sdd-design-cheap.agent.md", + "sdd-tasks-cheap.agent.md", + "sdd-apply-cheap.agent.md", + "sdd-verify-cheap.agent.md", + "sdd-archive-cheap.agent.md", + "sdd-onboard-cheap.agent.md", + } + userFiles := []string{ + "my-custom.agent.md", + "helper.agent.md", + "notes.txt", + } + + for _, f := range gentleAISDD { + if err := os.WriteFile(filepath.Join(agentsDir, f), []byte("content"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", f, err) + } + } + for _, f := range userFiles { + if err := os.WriteFile(filepath.Join(agentsDir, f), []byte("user content"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", f, err) + } + } + + err := RemoveVSCodeProfileAgents(agentsDir, "cheap") + if err != nil { + t.Fatalf("RemoveVSCodeProfileAgents() error = %v", err) + } + + // All gentle-ai SDD files should be removed + for _, f := range gentleAISDD { + path := filepath.Join(agentsDir, f) + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Errorf("file %q should have been removed but still exists", f) + } + } + + // User files should be preserved + for _, f := range userFiles { + path := filepath.Join(agentsDir, f) + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + t.Errorf("user file %q should have been preserved but was removed", f) + } + } +} + +func TestRemoveVSCodeProfileAgents_RejectsDefaultProfile(t *testing.T) { + for _, name := range []string{"", "default"} { + t.Run(name, func(t *testing.T) { + err := RemoveVSCodeProfileAgents(t.TempDir(), name) + if err == nil { + t.Fatalf("RemoveVSCodeProfileAgents(%q) should return error for default profile", name) + } + if !strings.Contains(err.Error(), "cannot remove default profile") { + t.Fatalf("RemoveVSCodeProfileAgents(%q) error = %q, want 'cannot remove default profile'", name, err.Error()) + } + }) + } +} + +func TestRemoveVSCodeProfileAgents_SilentlySkipsMissingFiles(t *testing.T) { + agentsDir := t.TempDir() + // No files exist at all — should not error + err := RemoveVSCodeProfileAgents(agentsDir, "nonexistent") + if err != nil { + t.Fatalf("RemoveVSCodeProfileAgents() on empty dir error = %v", err) + } +} + +func TestRemoveVSCodeProfileAgents_NonexistentDirIsNoop(t *testing.T) { + err := RemoveVSCodeProfileAgents(filepath.Join(t.TempDir(), "does-not-exist"), "cheap") + if err != nil { + t.Fatalf("RemoveVSCodeProfileAgents() on nonexistent dir error = %v", err) + } +} \ No newline at end of file diff --git a/internal/assets/assets.go b/internal/assets/assets.go index f24bdae2e..1bddd8664 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -2,7 +2,7 @@ package assets import "embed" -//go:embed all:claude all:opencode all:generic all:skills all:gga all:gemini all:codex all:antigravity all:windsurf all:cursor all:kimi all:qwen all:kiro +//go:embed all:claude all:opencode all:generic all:skills all:gga all:gemini all:codex all:antigravity all:windsurf all:cursor all:kimi all:qwen all:kiro all:vscode var FS embed.FS // MustRead returns the content of an embedded file or panics. diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go index 348f88ca5..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/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..e190a5841 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) @@ -571,6 +601,13 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt // Copy all files (not just .md) to support Kimi's YAML-based agents contentStr := assets.MustRead(embeddedDir + "/" + entry.Name()) + // Normalize line endings to LF before sentinel replacement. + // Embedded templates may contain CRLF (e.g. Windows checkouts). + // Without normalization, replacements like "model: {{VSC_MODEL}}\n" + // silently fail because they only match LF — leaving raw sentinels + // in the output file, which causes YAML parse errors in Copilot. + contentStr = strings.ReplaceAll(contentStr, "\r\n", "\n") + // Resolve {{KIRO_MODEL}} placeholder for adapters that support it (e.g. Kiro). // Non-Kiro adapters (Cursor, etc.) don't implement kiroModelResolver and are unaffected. if kmr, ok := adapter.(kiroModelResolver); ok { @@ -600,6 +637,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 +681,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/inject_test.go b/internal/components/sdd/inject_test.go index 4cb0a8f99..58e1d9aef 100644 --- a/internal/components/sdd/inject_test.go +++ b/internal/components/sdd/inject_test.go @@ -926,6 +926,52 @@ func TestInjectVSCodeWritesSDDOrchestratorAndSkills(t *testing.T) { } } +func TestInjectVSCodeStripsModelSentinelEvenWithCRLF(t *testing.T) { + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + vscodeAdapter, err := agents.NewAdapter("vscode-copilot") + if err != nil { + t.Fatalf("NewAdapter(vscode-copilot) error = %v", err) + } + + // Inject with NO model assignments — this is the default path where + // VSCModelID returns "" and the sentinel must be removed entirely. + result, injectErr := Inject(home, vscodeAdapter, "") + if injectErr != nil { + t.Fatalf("Inject(vscode) error = %v", injectErr) + } + if !result.Changed { + t.Fatal("Inject(vscode) changed = false") + } + + // Verify NO agent file contains the raw {{VSC_MODEL}} sentinel. + // This was a real bug: templates with CRLF line endings caused + // strings.ReplaceAll("model: {{VSC_MODEL}}\n", "") to miss the + // \r\n variant, leaving the raw placeholder in the output. + agentsDir := vscodeAdapter.SubAgentsDir(home) + entries, err := os.ReadDir(agentsDir) + if err != nil { + t.Fatalf("ReadDir(%q) error = %v", agentsDir, err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + content, readErr := os.ReadFile(filepath.Join(agentsDir, entry.Name())) + if readErr != nil { + t.Fatalf("ReadFile(%q) error = %v", entry.Name(), readErr) + } + if strings.Contains(string(content), "{{VSC_MODEL}}") { + t.Fatalf("agent %q still contains raw {{VSC_MODEL}} sentinel — CRLF normalization failed", entry.Name()) + } + if strings.Contains(string(content), "{{VSC_PROFILE_SUFFIX}}") { + t.Fatalf("agent %q still contains raw {{VSC_PROFILE_SUFFIX}} sentinel", entry.Name()) + } + } +} + func TestInjectFileAppendSkipsIfAlreadyPresent(t *testing.T) { home := t.TempDir() 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