diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go index 6cda8e885..3d4796c4a 100644 --- a/internal/cli/sync_test.go +++ b/internal/cli/sync_test.go @@ -2210,30 +2210,24 @@ func TestRunSyncWithProfilesIntegration(t *testing.T) { t.Errorf("opencode.json should contain balanced orchestrator model 'claude-sonnet-4-5'") } - // Verify prompt files exist in ~/.config/opencode/prompts/sdd/. + // Verify prompt references are relative to the generated OpenCode settings. // Note: prompt files are written only for multi-mode. For single-mode syncs, // profile sub-agents use {file:...} references that rely on prompts being written // during a prior multi-mode sync. Check that the profile overlay is written correctly // by verifying the agent keys themselves are present (already done above). - // The prompt directory is populated by the profile generator which calls - // SharedPromptDir internally — verify the directory path is referenced correctly. - promptDir := filepath.Join(home, ".config", "opencode", "prompts", "sdd") promptPhases := []string{ "sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard", } - // Verify the opencode.json file references mention the correct prompt directory. - slashPromptDir := filepath.ToSlash(promptDir) - if !strings.Contains(settingsStr, slashPromptDir) { - t.Errorf("opencode.json should reference prompt directory %q", slashPromptDir) - } - // Verify all phase prompt file references appear in the settings. for _, phase := range promptPhases { - promptRef := filepath.ToSlash(filepath.Join(promptDir, phase+".md")) + promptRef := "{file:./prompts/sdd/" + phase + ".md}" if !strings.Contains(settingsStr, promptRef) { t.Errorf("opencode.json should contain prompt file reference for %q", promptRef) } } + if slashHome := filepath.ToSlash(home); strings.Contains(settingsStr, slashHome) { + t.Errorf("opencode.json should not contain temp home path %q", slashHome) + } // Run 2: same selection → all assets already current → filesChanged=0. // Note: The second sync with profiles will re-generate the overlay, but since diff --git a/internal/components/golden_test.go b/internal/components/golden_test.go index 7617f4df3..c7e64502d 100644 --- a/internal/components/golden_test.go +++ b/internal/components/golden_test.go @@ -175,14 +175,7 @@ func TestGoldenSDD_OpenCode_Multi(t *testing.T) { t.Fatalf("multi-mode settings missing orchestrator tool %s", toolName) } } - // Normalize the absolute home path in the settings JSON so the golden - // file remains stable across test runs (temp dirs change each run). - // Sub-agent prompts now use {file:/abs/path/...} references. - jsonStr := string(settingsJSON) - jsonStr = strings.ReplaceAll(jsonStr, home, "{{HOME}}") - jsonStr = strings.ReplaceAll(jsonStr, filepath.ToSlash(home), "{{HOME}}") - normalizedSettings := []byte(jsonStr) - assertGolden(t, "sdd-opencode-multi-settings.golden", normalizedSettings) + assertGolden(t, "sdd-opencode-multi-settings.golden", settingsJSON) legacyPluginPath := filepath.Join(home, ".config", "opencode", "plugins", "background-agents.ts") if _, err := os.Stat(legacyPluginPath); !os.IsNotExist(err) { diff --git a/internal/components/sdd/inject.go b/internal/components/sdd/inject.go index 583377d5d..c43526c59 100644 --- a/internal/components/sdd/inject.go +++ b/internal/components/sdd/inject.go @@ -539,7 +539,7 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt return InjectionResult{}, fmt.Errorf("clean stale profile JD agents %q: %w", profile.Name, cleanupErr) } changed = changed || cleanupResult.Changed - profileOverlay, profileErr := GenerateProfileOverlay(profile, homeDir, opts.OpenCodeModelAssignments, opts.CodeGraphGuidanceMarkdown) + profileOverlay, profileErr := GenerateProfileOverlay(profile, homeDir, settingsPath, opts.OpenCodeModelAssignments, opts.CodeGraphGuidanceMarkdown) if profileErr != nil { return InjectionResult{}, fmt.Errorf("generate profile overlay %q: %w", profile.Name, profileErr) } @@ -913,10 +913,9 @@ func inlineOpenCodeSDDPrompts(overlayBytes []byte, homeDir, settingsPath string, } } - // Replace sub-agent prompt placeholders with {file:} references. + // Replace sub-agent prompt placeholders with settings-relative file references. // The placeholder format is __PROMPT_FILE_{phase}__ where {phase} is the agent name. if homeDir != "" { - promptDir := SharedPromptDir(homeDir) for _, phase := range subAgentPhaseOrder { agentRaw, exists := agentsMap[phase] if !exists { @@ -928,7 +927,11 @@ func inlineOpenCodeSDDPrompts(overlayBytes []byte, homeDir, settingsPath string, } placeholder := "__PROMPT_FILE_" + phase + "__" if prompt, _ := agentMap["prompt"].(string); prompt == placeholder { - agentMap["prompt"] = "{file:" + filepath.ToSlash(filepath.Join(promptDir, phase+".md")) + "}" + promptRef, err := SharedPromptFileRef(settingsPath, homeDir, phase) + if err != nil { + return nil, fmt.Errorf("build shared prompt file reference for %q: %w", phase, err) + } + agentMap["prompt"] = promptRef } } } diff --git a/internal/components/sdd/inject_test.go b/internal/components/sdd/inject_test.go index 02805da94..c76962432 100644 --- a/internal/components/sdd/inject_test.go +++ b/internal/components/sdd/inject_test.go @@ -2343,13 +2343,17 @@ func TestInjectOpenCodeSubagentPromptsStayExecutorScoped(t *testing.T) { t.Fatalf("%s has unexpected type: %T", phase, raw) } - // After the shared-prompt-files refactor, the prompt field is a {file:...} - // reference. The executor-scoped content lives in the prompt file on disk. prompt, _ := agentDef["prompt"].(string) - expectedRef := "{file:" + filepath.ToSlash(filepath.Join(promptDir, phase+".md")) + "}" + expectedRef, err := SharedPromptFileRef(settingsPath, home, phase) + if err != nil { + t.Fatalf("SharedPromptFileRef() error = %v", err) + } if prompt != expectedRef { t.Fatalf("%s prompt = %q, want {file:...} reference %q", phase, prompt, expectedRef) } + if strings.Contains(prompt, filepath.ToSlash(home)) { + t.Fatalf("%s prompt = %q, contains home path", phase, prompt) + } // Also verify the prompt file contains the executor-scoped content // (skill content that makes clear this is the executor, not orchestrator). @@ -2372,6 +2376,27 @@ func TestInjectOpenCodeSubagentPromptsStayExecutorScoped(t *testing.T) { } } +func TestInjectKilocodeSubagentPromptUsesSharedRelativePath(t *testing.T) { + home := t.TempDir() + adapter := kilocodeAdapter() + + if _, err := Inject(home, adapter, "multi"); err != nil { + t.Fatalf("Inject(multi) error = %v", err) + } + + prompt := agentPrompt(t, readOpenCodeAgents(t, adapter.SettingsPath(home)), "sdd-apply") + want, err := SharedPromptFileRef(adapter.SettingsPath(home), home, "sdd-apply") + if err != nil { + t.Fatalf("SharedPromptFileRef() error = %v", err) + } + if prompt != want { + t.Fatalf("sdd-apply prompt = %q, want %q", prompt, want) + } + if strings.Contains(prompt, filepath.ToSlash(home)) { + t.Fatalf("sdd-apply prompt = %q, contains home path", prompt) + } +} + func TestInjectOpenCodeEmptySDDModeDefaultsSingle(t *testing.T) { mockNoPackageManager(t) home := t.TempDir() diff --git a/internal/components/sdd/issue557_test.go b/internal/components/sdd/issue557_test.go index 3f1a7420f..191140a94 100644 --- a/internal/components/sdd/issue557_test.go +++ b/internal/components/sdd/issue557_test.go @@ -57,7 +57,7 @@ func TestGenerateProfileOverlay_FallsBackToGlobalAssignments(t *testing.T) { "sdd-archive": {ProviderID: "openai", ModelID: "gpt-5.1"}, } - overlay, err := GenerateProfileOverlay(profile, home, globalAssignments, "") + overlay, err := GenerateProfileOverlay(profile, home, openCodeSettingsPathForTest(home), globalAssignments, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } diff --git a/internal/components/sdd/profiles.go b/internal/components/sdd/profiles.go index e10eabc69..b2fd2c59e 100644 --- a/internal/components/sdd/profiles.go +++ b/internal/components/sdd/profiles.go @@ -243,20 +243,7 @@ func extractModelFromAgent(agentMap map[string]any) model.ModelAssignment { // sub-agent references and model assignments table), permissions scoped to *-{name} // - sdd-{phase}-{name} (10 agents): subagent mode, hidden, file reference to // the shared prompt at SharedPromptDir(homeDir)/sdd-{phase}.md -// -// fallbackPhaseAssignments (optional) is consulted when a phase is not -// explicitly set on the profile. Typically this is the caller's -// Selection.ModelAssignments from the gentle-ai TUI's global "Configure -// Models" flow. Without the fallback, a phase the user assigned globally but -// did not re-touch inside the profile picker would be silently emitted -// without a model field, surfacing as "Unassigned" in OpenCode while -// gentle-ai's UI showed the phase as assigned (issue #557). Profile-level -// assignments always win over the fallback when both are present. -// -// codeGraphGuidance (optional) is markdown appended to sub-agent prompts that -// instructs the agent how to use the CodeGraph community tool. Pass an empty -// string when CodeGraph is not enabled. -func GenerateProfileOverlay(profile model.Profile, homeDir string, fallbackPhaseAssignments map[string]model.ModelAssignment, codeGraphGuidance string) ([]byte, error) { +func GenerateProfileOverlay(profile model.Profile, homeDir, settingsPath string, fallbackPhaseAssignments map[string]model.ModelAssignment, codeGraphGuidance string) ([]byte, error) { if profile.Name == "" || profile.Name == "default" { return nil, fmt.Errorf("GenerateProfileOverlay: profile name must be non-empty and not 'default'") } @@ -344,7 +331,6 @@ func GenerateProfileOverlay(profile model.Profile, homeDir string, fallbackPhase agentMap[orchestratorKey] = orchEntry // Sub-agent entries - promptDir := SharedPromptDir(homeDir) phaseDescriptions := map[string]string{ "sdd-init": "Bootstrap SDD context and project configuration", "sdd-explore": "Investigate codebase and think through ideas", @@ -360,7 +346,10 @@ func GenerateProfileOverlay(profile model.Profile, homeDir string, fallbackPhase for _, phase := range profilePhaseOrder { key := phase + suffix - prompt := "{file:" + filepath.ToSlash(filepath.Join(promptDir, phase+".md")) + "}" + prompt, err := SharedPromptFileRef(settingsPath, homeDir, phase) + if err != nil { + return nil, fmt.Errorf("build shared prompt file reference for %q: %w", phase, err) + } entry := map[string]any{ "mode": "subagent", "hidden": true, diff --git a/internal/components/sdd/profiles_lifecycle_test.go b/internal/components/sdd/profiles_lifecycle_test.go index d6f10d172..a09f984ab 100644 --- a/internal/components/sdd/profiles_lifecycle_test.go +++ b/internal/components/sdd/profiles_lifecycle_test.go @@ -58,7 +58,7 @@ func TestProfileLifecycle_FullCRUD(t *testing.T) { OrchestratorModel: haikuModel, } - overlayBytes, err := GenerateProfileOverlay(cheapProfile, home, nil, "") + overlayBytes, err := GenerateProfileOverlay(cheapProfile, home, settingsPath, nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay(): %v", err) } @@ -121,7 +121,7 @@ func TestProfileLifecycle_FullCRUD(t *testing.T) { Name: "cheap", OrchestratorModel: sonnetModel, } - editOverlayBytes, err := GenerateProfileOverlay(editedProfile, home, nil, "") + editOverlayBytes, err := GenerateProfileOverlay(editedProfile, home, settingsPath, nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() for edit: %v", err) } @@ -224,7 +224,7 @@ func TestProfileLifecycle_TwoProfiles(t *testing.T) { cheapOverlay, err := GenerateProfileOverlay(model.Profile{ Name: "cheap", OrchestratorModel: model.ModelAssignment{ProviderID: "anthropic", ModelID: "claude-haiku-3-5"}, - }, home, nil, "") + }, home, settingsPath, nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay(cheap): %v", err) } @@ -233,7 +233,7 @@ func TestProfileLifecycle_TwoProfiles(t *testing.T) { premiumOverlay, err := GenerateProfileOverlay(model.Profile{ Name: "premium", OrchestratorModel: model.ModelAssignment{ProviderID: "anthropic", ModelID: "claude-opus-4-5"}, - }, home, nil, "") + }, home, settingsPath, nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay(premium): %v", err) } diff --git a/internal/components/sdd/profiles_test.go b/internal/components/sdd/profiles_test.go index 5ce564cd3..1c6200de8 100644 --- a/internal/components/sdd/profiles_test.go +++ b/internal/components/sdd/profiles_test.go @@ -403,10 +403,14 @@ func makeHaikuProfile() model.Profile { } } +func openCodeSettingsPathForTest(home string) string { + return filepath.Join(home, ".config", "opencode", "opencode.json") +} + func TestGenerateProfileOverlay_Structure(t *testing.T) { home := t.TempDir() - overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, nil, "") + overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home), nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } @@ -479,7 +483,7 @@ func TestGenerateProfileOverlay_Structure(t *testing.T) { func TestGenerateProfileOverlay_PermissionScoped(t *testing.T) { home := t.TempDir() - overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, nil, "") + overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home), nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } @@ -524,7 +528,7 @@ func TestGenerateProfileOverlay_JDAssignmentsGenerateSuffixedAgents(t *testing.T profile.PhaseAssignments["jd-judge-b"] = model.ModelAssignment{ProviderID: "openai", ModelID: "gpt-5.1"} profile.PhaseAssignments["jd-fix-agent"] = model.ModelAssignment{ProviderID: "anthropic", ModelID: "claude-sonnet-4-20250514"} - overlay, err := GenerateProfileOverlay(profile, home, nil, "") + overlay, err := GenerateProfileOverlay(profile, home, openCodeSettingsPathForTest(home), nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } @@ -597,7 +601,7 @@ func TestGenerateProfileOverlay_JDAssignmentsGenerateSuffixedAgents(t *testing.T func TestGenerateProfileOverlay_NoJDAssignmentsUsesGlobalJDAgents(t *testing.T) { home := t.TempDir() - overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, nil, "") + overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home), nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } @@ -632,7 +636,7 @@ func TestGenerateProfileOverlay_NoJDAssignmentsUsesGlobalJDAgents(t *testing.T) func TestGenerateProfileOverlay_ToolsUseReplaceSentinel(t *testing.T) { home := t.TempDir() - overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, nil, "") + overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home), nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } @@ -733,7 +737,7 @@ func TestDefaultOverlayToolsUseReplaceSentinel(t *testing.T) { func TestGenerateProfileOverlay_TaskPermissionsBlockCrossProfileDelegation(t *testing.T) { home := t.TempDir() - overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, nil, "") + overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home), nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } @@ -771,13 +775,11 @@ func TestGenerateProfileOverlay_TaskPermissionsBlockCrossProfileDelegation(t *te func TestGenerateProfileOverlay_SubAgentFileRefs(t *testing.T) { home := t.TempDir() - overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, nil, "") + overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home), nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } - promptDir := SharedPromptDir(home) - var root map[string]any if err := json.Unmarshal(overlay, &root); err != nil { t.Fatalf("overlay is not valid JSON: %v", err) @@ -788,7 +790,10 @@ func TestGenerateProfileOverlay_SubAgentFileRefs(t *testing.T) { key := phase + "-cheap" agent := agentMap[key].(map[string]any) prompt, _ := agent["prompt"].(string) - expectedRef := "{file:" + filepath.ToSlash(filepath.Join(promptDir, phase+".md")) + "}" + expectedRef, err := SharedPromptFileRef(openCodeSettingsPathForTest(home), home, phase) + if err != nil { + t.Fatalf("SharedPromptFileRef() error = %v", err) + } if prompt != expectedRef { t.Errorf("sub-agent %q prompt = %q, want %q", key, prompt, expectedRef) } @@ -798,7 +803,7 @@ func TestGenerateProfileOverlay_SubAgentFileRefs(t *testing.T) { func TestGenerateProfileOverlay_OrchestratorPromptSuffixed(t *testing.T) { home := t.TempDir() - overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, nil, "") + overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home), nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } @@ -1106,7 +1111,7 @@ func TestGenerateProfileOverlay_VariantInjected(t *testing.T) { }, } - overlay, err := GenerateProfileOverlay(profile, home, nil, "") + overlay, err := GenerateProfileOverlay(profile, home, openCodeSettingsPathForTest(home), nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } @@ -1138,7 +1143,7 @@ func TestGenerateProfileOverlay_EmptyEffortClearsVariant(t *testing.T) { }, } - overlay, err := GenerateProfileOverlay(profile, home, nil, "") + overlay, err := GenerateProfileOverlay(profile, home, openCodeSettingsPathForTest(home), nil, "") if err != nil { t.Fatalf("GenerateProfileOverlay() error = %v", err) } diff --git a/internal/components/sdd/prompts.go b/internal/components/sdd/prompts.go index ec248f63e..49266e2f1 100644 --- a/internal/components/sdd/prompts.go +++ b/internal/components/sdd/prompts.go @@ -4,6 +4,7 @@ import ( "path/filepath" "strings" + "github.com/gentleman-programming/gentle-ai/internal/agents/opencode" "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" @@ -19,10 +20,29 @@ func readSkillContent(phase string) (string, error) { return assets.Read("skills/" + phase + "/SKILL.md") } -// SharedPromptDir returns the directory where shared SDD prompt files are stored. -// The path is {homeDir}/.config/opencode/prompts/sdd. +// SharedPromptDir returns the shared SDD prompt directory beside OpenCode's +// settings file, including when XDG_CONFIG_HOME overrides the default root. func SharedPromptDir(homeDir string) string { - return filepath.Join(homeDir, ".config", "opencode", "prompts", "sdd") + return filepath.Join(opencode.ConfigPath(homeDir), "prompts", "sdd") +} + +// SharedPromptFileRef returns a prompt file reference relative to the settings +// file that will contain it. +func SharedPromptFileRef(settingsPath, homeDir, phase string) (string, error) { + return sharedPromptFileRef(settingsPath, homeDir, phase, filepath.Rel) +} + +func sharedPromptFileRef(settingsPath, homeDir, phase string, rel func(string, string) (string, error)) (string, error) { + promptPath := filepath.Join(SharedPromptDir(homeDir), phase+".md") + relativePath, err := rel(filepath.Dir(settingsPath), promptPath) + if err != nil { + return "{file:" + filepath.ToSlash(promptPath) + "}", nil + } + relativePath = filepath.ToSlash(relativePath) + if !strings.HasPrefix(relativePath, ".") { + relativePath = "./" + relativePath + } + return "{file:" + relativePath + "}", nil } // subAgentPhaseOrder is an alias for profilePhaseOrder (defined in profiles.go), diff --git a/internal/components/sdd/prompts_test.go b/internal/components/sdd/prompts_test.go index 8291963c8..5accf9111 100644 --- a/internal/components/sdd/prompts_test.go +++ b/internal/components/sdd/prompts_test.go @@ -2,6 +2,7 @@ package sdd import ( "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -22,6 +23,76 @@ func TestSharedPromptDir(t *testing.T) { } } +func TestSharedPromptDirUsesXDGConfigHome(t *testing.T) { + home := t.TempDir() + xdgConfigHome := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + + want := filepath.Join(xdgConfigHome, "opencode", "prompts", "sdd") + if got := SharedPromptDir(home); got != want { + t.Fatalf("SharedPromptDir() = %q, want %q", got, want) + } +} + +func TestSharedPromptFileRefFallsBackToAbsolutePath(t *testing.T) { + home := t.TempDir() + xdgConfigHome := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + + got, err := sharedPromptFileRef( + filepath.Join(xdgConfigHome, "opencode", "opencode.json"), + home, + "sdd-apply", + func(string, string) (string, error) { return "", errors.New("different volume") }, + ) + if err != nil { + t.Fatalf("sharedPromptFileRef() error = %v", err) + } + want := "{file:" + filepath.ToSlash(filepath.Join(xdgConfigHome, "opencode", "prompts", "sdd", "sdd-apply.md")) + "}" + if got != want { + t.Fatalf("sharedPromptFileRef() = %q, want %q", got, want) + } +} + +func TestSharedPromptFileRef(t *testing.T) { + home := t.TempDir() + tests := []struct { + name string + settingsPath string + want string + }{ + { + name: "OpenCode settings", + settingsPath: filepath.Join(home, ".config", "opencode", "opencode.json"), + want: "{file:./prompts/sdd/sdd-apply.md}", + }, + { + name: "Kilocode settings", + settingsPath: filepath.Join(home, ".config", "kilo", "opencode.json"), + want: "{file:../opencode/prompts/sdd/sdd-apply.md}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := SharedPromptFileRef(tt.settingsPath, home, "sdd-apply") + if err != nil { + t.Fatalf("SharedPromptFileRef() error = %v", err) + } + if got != tt.want { + t.Fatalf("SharedPromptFileRef() = %q, want %q", got, tt.want) + } + if strings.Contains(got, filepath.ToSlash(home)) { + t.Fatalf("SharedPromptFileRef() = %q, contains home path", got) + } + }) + } +} + func readOpenCodeAgents(t *testing.T, settingsPath string) map[string]any { t.Helper() content, err := os.ReadFile(settingsPath) @@ -391,16 +462,19 @@ func TestInjectOpenCodeMultiModeSubagentPromptsUseFilePaths(t *testing.T) { t.Fatalf("ReadFile(opencode.json) error = %v", err) } - promptDir := SharedPromptDir(home) - - text := strings.ReplaceAll(string(content), `\\`, `/`) + text := string(content) for _, phase := range []string{"sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard"} { - expectedRef := "{file:" + filepath.Join(promptDir, phase+".md") + "}" - expectedRef = strings.ReplaceAll(expectedRef, `\`, `/`) + expectedRef, err := SharedPromptFileRef(settingsPath, home, phase) + if err != nil { + t.Fatalf("SharedPromptFileRef() error = %v", err) + } if !strings.Contains(text, expectedRef) { t.Errorf("opencode.json sub-agent %q missing {file:...} reference %q", phase, expectedRef) } } + if strings.Contains(text, filepath.ToSlash(home)) { + t.Fatalf("opencode.json contains home-specific absolute path %q", filepath.ToSlash(home)) + } } func TestWriteSharedPromptFilesOmitCodeGraphGuidanceByDefault(t *testing.T) { diff --git a/testdata/golden/sdd-opencode-multi-settings.golden b/testdata/golden/sdd-opencode-multi-settings.golden index f90c6f70e..8346dcc22 100644 --- a/testdata/golden/sdd-opencode-multi-settings.golden +++ b/testdata/golden/sdd-opencode-multi-settings.golden @@ -153,7 +153,7 @@ "description": "Implement code changes from task definitions", "hidden": true, "mode": "subagent", - "prompt": "{file:{{HOME}}/.config/opencode/prompts/sdd/sdd-apply.md}", + "prompt": "{file:./prompts/sdd/sdd-apply.md}", "tools": { "bash": true, "edit": true, @@ -165,7 +165,7 @@ "description": "Archive completed change artifacts", "hidden": true, "mode": "subagent", - "prompt": "{file:{{HOME}}/.config/opencode/prompts/sdd/sdd-archive.md}", + "prompt": "{file:./prompts/sdd/sdd-archive.md}", "tools": { "bash": true, "edit": true, @@ -177,7 +177,7 @@ "description": "Create technical design from proposals", "hidden": true, "mode": "subagent", - "prompt": "{file:{{HOME}}/.config/opencode/prompts/sdd/sdd-design.md}", + "prompt": "{file:./prompts/sdd/sdd-design.md}", "tools": { "bash": true, "edit": true, @@ -189,7 +189,7 @@ "description": "Investigate codebase and think through ideas", "hidden": true, "mode": "subagent", - "prompt": "{file:{{HOME}}/.config/opencode/prompts/sdd/sdd-explore.md}", + "prompt": "{file:./prompts/sdd/sdd-explore.md}", "tools": { "bash": true, "edit": true, @@ -201,7 +201,7 @@ "description": "Bootstrap SDD context and project configuration", "hidden": true, "mode": "subagent", - "prompt": "{file:{{HOME}}/.config/opencode/prompts/sdd/sdd-init.md}", + "prompt": "{file:./prompts/sdd/sdd-init.md}", "tools": { "bash": true, "edit": true, @@ -213,7 +213,7 @@ "description": "Guide user through a complete SDD cycle using their real codebase", "hidden": true, "mode": "subagent", - "prompt": "{file:{{HOME}}/.config/opencode/prompts/sdd/sdd-onboard.md}", + "prompt": "{file:./prompts/sdd/sdd-onboard.md}", "tools": { "bash": true, "edit": true, @@ -225,7 +225,7 @@ "description": "Create change proposals from explorations", "hidden": true, "mode": "subagent", - "prompt": "{file:{{HOME}}/.config/opencode/prompts/sdd/sdd-propose.md}", + "prompt": "{file:./prompts/sdd/sdd-propose.md}", "tools": { "bash": true, "edit": true, @@ -237,7 +237,7 @@ "description": "Write detailed specifications from proposals", "hidden": true, "mode": "subagent", - "prompt": "{file:{{HOME}}/.config/opencode/prompts/sdd/sdd-spec.md}", + "prompt": "{file:./prompts/sdd/sdd-spec.md}", "tools": { "bash": true, "edit": true, @@ -249,7 +249,7 @@ "description": "Break down specs and designs into implementation tasks", "hidden": true, "mode": "subagent", - "prompt": "{file:{{HOME}}/.config/opencode/prompts/sdd/sdd-tasks.md}", + "prompt": "{file:./prompts/sdd/sdd-tasks.md}", "tools": { "bash": true, "edit": true, @@ -261,7 +261,7 @@ "description": "Validate implementation against specs", "hidden": true, "mode": "subagent", - "prompt": "{file:{{HOME}}/.config/opencode/prompts/sdd/sdd-verify.md}", + "prompt": "{file:./prompts/sdd/sdd-verify.md}", "tools": { "bash": true, "edit": true,