Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions internal/materialize/renderers/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -517,19 +517,26 @@ func (r *CopilotRenderer) InstallWorkflow(wf model.WorkflowManifest, cachePath s
continue
}

// Copy everything else (e.g. _shared/) under skillsBase/<orchRoleName>/
// so that the orchestrator .agent.md can reference them via paths such as
// .github/skills/sdd-orchestrator/_shared/launch-templates.md.
// In Copilot's model, agents/ is flat (.agent.md files only); content lives in skills/.
// Apply variant-suffix stripping for _shared/ so that launch-templates.copilot.md →
// _shared/ is a workflow-level asset (launch-templates, advisor-templates,
// envelope-contract, persistence-contract, recovery). For Copilot it
// installs to .github/<workflow-name>/_shared/ — outside the legacy
// skills/<orch>/ container which is empty for primary-agent installs
// (Copilot delivers the orchestrator as a flat .agent.md).
// In Copilot's model, agents/ is flat (.agent.md files only); content
// lives in skills/ for actual sub-agent skills. Apply variant-suffix
// stripping for _shared/ so that launch-templates.copilot.md →
// launch-templates.md and files for other variants are skipped entirely.
dstPath := filepath.Join(orchSkillDir, name)
var dstPath string
if entry.IsDir() && name == "_shared" {
dstPath = filepath.Join(workspaceRoot, wf.Metadata.Name, "_shared")
if err := copyDirRecursiveStripVariant(srcPath, dstPath, "copilot"); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("copilot: workflow copy %q: %w", name, err)
}
} else if err := copyEntry(srcPath, dstPath, entry); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("copilot: workflow copy %q: %w", name, err)
} else {
dstPath = filepath.Join(orchSkillDir, name)
if err := copyEntry(srcPath, dstPath, entry); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("copilot: workflow copy %q: %w", name, err)
}
}
managedPaths = append(managedPaths, dstPath)
}
Expand Down Expand Up @@ -607,13 +614,21 @@ func (r *CopilotRenderer) InstallWorkflow(wf model.WorkflowManifest, cachePath s
// instructions file.

// Resolve placeholders in all installed .md files under skillsBase and agentsBase.
// agentsBase now contains the orchestrator .agent.md and the _shared/ directory.
if err := resolvePlaceholders(skillsBase, replacements); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("copilot: resolve placeholders (skills): %w", err)
}
if err := resolvePlaceholders(agentsBase, replacements); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("copilot: resolve placeholders (agents): %w", err)
}
// Resolve placeholders in the workflow's shared assets — _shared/ now lives
// at .github/<workflow-name>/_shared/, outside the legacy skills/<orch>/
// container.
workflowSharedRoot := filepath.Join(workspaceRoot, wf.Metadata.Name)
if _, statErr := os.Stat(workflowSharedRoot); statErr == nil {
if err := resolvePlaceholders(workflowSharedRoot, replacements); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("copilot: resolve workflow shared placeholders: %w", err)
}
}

// Remove any lines containing unresolved {SDD_MODEL_*} or {WORKFLOW_MODEL_*} placeholders.
// These correspond to phases where the user selected "inherit from session" (no override).
Expand Down
25 changes: 15 additions & 10 deletions internal/materialize/renderers/copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,19 +398,24 @@ func TestCopilotRenderer_InstallWorkflow_SkillsUnderSkillsDir(t *testing.T) {
t.Errorf("expected %s to exist: %v", skillMD, err)
}

// POSITIVE: _shared directory under skills/sdd-orchestrator/_shared
// (matches the paths the orchestrator .agent.md references, e.g.
// .github/skills/sdd-orchestrator/_shared/launch-templates.md)
sharedDest := filepath.Join(workspaceRoot, "skills", "sdd-orchestrator", "_shared")
// POSITIVE: _shared installed at the workflow-namespaced top-level path
// .github/<workflow-name>/_shared/. The orchestrator .agent.md references
// it via {SHARED_DIR}, which the renderer resolves to this path.
sharedDest := filepath.Join(workspaceRoot, wf.Metadata.Name, "_shared")
if info, err := os.Stat(sharedDest); err != nil || !info.IsDir() {
t.Errorf("expected %s to be a directory: err=%v", sharedDest, err)
}

// NEGATIVE: _shared must NOT be installed under agents/sdd-orchestrator/
// (agents/ is flat: only .agent.md files, no subdirectories)
sharedInAgents := filepath.Join(workspaceRoot, "agents", "sdd-orchestrator", "_shared")
if _, err := os.Stat(sharedInAgents); err == nil {
t.Error("_shared must NOT exist under agents/sdd-orchestrator/ for Copilot — it belongs in skills/sdd-orchestrator/")
// NEGATIVE: legacy paths must NOT be created — the orchestrator skill dir
// in skills/ was a fantasma container (no SKILL.md), and agents/ is flat
// (only .agent.md files, no subdirectories).
for _, legacy := range []string{
filepath.Join(workspaceRoot, "skills", "sdd-orchestrator", "_shared"),
filepath.Join(workspaceRoot, "agents", "sdd-orchestrator", "_shared"),
} {
if _, err := os.Stat(legacy); err == nil {
t.Errorf("legacy fantasma path %s should NOT be created for Copilot installs", legacy)
}
}

// POSITIVE: orchestrator surfaced as native .agent.md in agents/
Expand Down Expand Up @@ -2062,7 +2067,7 @@ func TestCopilotRenderer_InstallWorkflow_SharedVariantSuffixStripping(t *testing
t.Fatalf("InstallWorkflow: %v", err)
}

installedShared := filepath.Join(workspaceRoot, "skills", "sdd-orchestrator", "_shared")
installedShared := filepath.Join(workspaceRoot, wf.Metadata.Name, "_shared")

// launch-templates.md must exist with copilot content (from launch-templates.copilot.md).
ltPath := filepath.Join(installedShared, "launch-templates.md")
Expand Down
22 changes: 22 additions & 0 deletions internal/materialize/renderers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,9 +798,27 @@ func buildWorkflowPlaceholderReplacements(
workflowDir = skillsPath
}

// {SHARED_DIR} resolves to the directory where the renderer copies the
// workflow's _shared/ assets. For Claude/Factory/Codex this stays inside
// the orchestrator skill (workflowDir/_shared) — `_shared/` legitimately
// belongs to the orchestrator skill there. For OpenCode/Copilot the
// orchestrator is a primary agent (not a skill on disk), so we install
// _shared/ at workspaceDir/<workflow-name>/_shared/ — a top-level
// workflow-namespaced location with no fantasma orchestrator skill dir.
// The catalog uses {SHARED_DIR} in template references; each renderer
// resolves it to its idiomatic path so the same template works for all.
var sharedDir string
switch agentName {
case "opencode", "copilot":
sharedDir = filepath.Clean(workspaceDir + "/" + wf.Metadata.Name + "/_shared")
default:
sharedDir = filepath.Clean(workflowDir + "/_shared")
}

replacements := map[string]string{
"{SKILLS_PATH}": skillsPath,
"{WORKFLOW_DIR}": workflowDir,
"{SHARED_DIR}": sharedDir,
}

wfName := wf.Metadata.Name
Expand Down Expand Up @@ -887,6 +905,10 @@ func buildWorkflowPathReplacements(wf model.WorkflowManifest, workspaceDir, skil
replacements := map[string]string{
"{SKILLS_PATH}": skillsPath,
"{WORKFLOW_DIR}": workflowDir,
// Codex/Factory keep _shared/ inside the orchestrator skill dir
// (their orchestrator IS a real skill on disk). Same path the
// legacy {WORKFLOW_DIR}/_shared/ resolved to.
"{SHARED_DIR}": filepath.Clean(workflowDir + "/_shared"),
}
// Add subagent placeholders defaulting to "general" for renderers without native agents.
wfName := wf.Metadata.Name
Expand Down
24 changes: 19 additions & 5 deletions internal/materialize/renderers/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -905,13 +905,14 @@ func TestBuildWorkflowPlaceholderReplacements_OpenCodeResolver(t *testing.T) {
// Tests for buildWorkflowPathReplacements
// ---------------------------------------------------------------------------

// TestBuildWorkflowPathReplacements_OnlySkillsPath verifies that the function
// returns only a {SKILLS_PATH} entry and no {SDD_MODEL_*} entries.
// TestBuildWorkflowPathReplacements_OnlyPathKeys verifies that the function
// returns only path entries ({SKILLS_PATH}, {WORKFLOW_DIR}, {SHARED_DIR}) and
// no {SDD_MODEL_*} entries.
func TestBuildWorkflowPathReplacements_OnlyPathKeys(t *testing.T) {
result := renderers.BuildWorkflowPathReplacements(model.WorkflowManifest{}, "/ws", "skills")

if len(result) != 2 {
t.Errorf("expected exactly 2 replacements, got %d: %v", len(result), result)
if len(result) != 3 {
t.Errorf("expected exactly 3 replacements, got %d: %v", len(result), result)
}
got, ok := result["{SKILLS_PATH}"]
if !ok {
Expand All @@ -928,6 +929,15 @@ func TestBuildWorkflowPathReplacements_OnlyPathKeys(t *testing.T) {
if gotWD != "/ws/skills" {
t.Errorf("{WORKFLOW_DIR} = %q, want %q (empty workingDir should yield skillsPath)", gotWD, "/ws/skills")
}
// SHARED_DIR is WORKFLOW_DIR/_shared for renderers without per-agent
// orchestrator pattern (Codex/Factory).
gotSD, ok := result["{SHARED_DIR}"]
if !ok {
t.Fatal("{SHARED_DIR} key missing from result")
}
if gotSD != "/ws/skills/_shared" {
t.Errorf("{SHARED_DIR} = %q, want %q (workflowDir/_shared for non-primary-agent renderers)", gotSD, "/ws/skills/_shared")
}
}

// TestBuildWorkflowPathReplacements_EmptySkillDir uses workspaceDir directly when skillDir is empty.
Expand Down Expand Up @@ -955,7 +965,11 @@ func TestBuildWorkflowPathReplacements_TrailingSlashStripped(t *testing.T) {
// regardless of the workflow manifest roles provided.
func TestBuildWorkflowPathReplacements_NoModelKeysEvenWithRoles(t *testing.T) {
result := renderers.BuildWorkflowPathReplacements(model.WorkflowManifest{}, "/project", "agents")
allowedKeys := map[string]bool{"{SKILLS_PATH}": true, "{WORKFLOW_DIR}": true}
allowedKeys := map[string]bool{
"{SKILLS_PATH}": true,
"{WORKFLOW_DIR}": true,
"{SHARED_DIR}": true,
}
for key := range result {
if !allowedKeys[key] {
t.Errorf("unexpected key %q — buildWorkflowPathReplacements must only produce path keys", key)
Expand Down
30 changes: 24 additions & 6 deletions internal/materialize/renderers/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,17 +376,25 @@ func (r *OpenCodeRenderer) InstallWorkflow(wf model.WorkflowManifest, cachePath
continue
}

// Copy everything else (e.g. _shared/) as-is under workflowDir.
// agents/ and commands/ directories are NOT created — OpenCode uses opencode.json.
// _shared/ is a workflow-level asset (launch-templates, advisor-templates,
// envelope-contract, persistence-contract, recovery). For OpenCode it
// installs to .opencode/<workflow-name>/_shared/ — outside the legacy
// .opencode/<workingDir>/ directory which is empty for primary-agent
// installs (no SKILL.md, the orchestrator lives in opencode.json).
// agents/ and commands/ directories are NOT created.
// Apply variant-suffix stripping for _shared/ so that launch-templates.opencode.md →
// launch-templates.md and files for other variants are skipped entirely.
dstPath := filepath.Join(workflowDir, name)
var dstPath string
if entry.IsDir() && name == "_shared" {
dstPath = filepath.Join(workspaceRoot, wf.Metadata.Name, "_shared")
if err := copyDirRecursiveStripVariant(srcPath, dstPath, "opencode"); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("opencode: workflow copy %q: %w", name, err)
}
} else if err := copyEntry(srcPath, dstPath, entry); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("opencode: workflow copy %q: %w", name, err)
} else {
dstPath = filepath.Join(workflowDir, name)
if err := copyEntry(srcPath, dstPath, entry); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("opencode: workflow copy %q: %w", name, err)
}
}
managedPaths = append(managedPaths, dstPath)
}
Expand Down Expand Up @@ -449,7 +457,17 @@ func (r *OpenCodeRenderer) InstallWorkflow(wf model.WorkflowManifest, cachePath
if err := resolvePlaceholders(skillsBase, replacements); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("opencode: resolve skill placeholders: %w", err)
}
// Resolve placeholders in workflow files under the workspace-local workflowDir (if it exists).
// Resolve placeholders in the workflow's shared assets (_shared/ now lives at
// the workflow-namespaced top-level path .opencode/<workflow-name>/_shared/,
// not inside a per-orchestrator skill dir).
workflowSharedRoot := filepath.Join(workspaceRoot, wf.Metadata.Name)
if _, statErr := os.Stat(workflowSharedRoot); statErr == nil {
if err := resolvePlaceholders(workflowSharedRoot, replacements); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("opencode: resolve workflow shared placeholders: %w", err)
}
}
// Backward-compat: a few catalogs may still drop content under workflowDir
// (e.g. legacy fixtures); resolve placeholders there too if it exists.
if _, statErr := os.Stat(workflowDir); statErr == nil {
if err := resolvePlaceholders(workflowDir, replacements); err != nil {
return matypes.WorkflowInstallResult{}, fmt.Errorf("opencode: resolve workflow placeholders: %w", err)
Expand Down
21 changes: 16 additions & 5 deletions internal/materialize/renderers/opencode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,8 @@ func sddParityManifest() model.WorkflowManifest {
}

// TestOpenCodeRenderer_InstallWorkflow_SkillsUnderSkillsDir verifies that workflow
// skills are installed under .agents/skills/, _shared/ is also copied there,
// skills are installed under .agents/skills/, _shared/ is installed at the
// workflow-namespaced top-level path .opencode/<workflow-name>/_shared/,
// and the old buggy agents/ path is never created.
func TestOpenCodeRenderer_InstallWorkflow_SkillsUnderSkillsDir(t *testing.T) {
projectRoot := t.TempDir()
Expand Down Expand Up @@ -457,15 +458,24 @@ func TestOpenCodeRenderer_InstallWorkflow_SkillsUnderSkillsDir(t *testing.T) {
t.Errorf("expected %s to exist: %v", skillMD, err)
}

// POSITIVE: _shared/ directory installed under .opencode/sdd-orchestrator/ (workspace-local, not shared)
sharedDir := filepath.Join(workspaceDir, "sdd-orchestrator", "_shared")
// POSITIVE: _shared/ installed at workflow-namespaced top-level path
// .opencode/<workflow-name>/_shared/ (NOT inside the legacy
// .opencode/<workingDir>/ container — the orchestrator is a primary agent
// in opencode.json, so there is no fantasma sdd-orchestrator skill dir).
sharedDir := filepath.Join(workspaceDir, wf.Metadata.Name, "_shared")
info, err := os.Stat(sharedDir)
if err != nil {
t.Errorf("expected %s to exist: %v", sharedDir, err)
} else if !info.IsDir() {
t.Errorf("expected %s to be a directory", sharedDir)
}

// NEGATIVE: the legacy fantasma path must NOT be created.
legacyShared := filepath.Join(workspaceDir, "sdd-orchestrator", "_shared")
if _, err := os.Stat(legacyShared); err == nil {
t.Errorf("legacy fantasma path %s should NOT be created for OpenCode installs", legacyShared)
}

// POSITIVE: opencode.json synthesized (from roles)
opencodeJSON := filepath.Join(workspaceDir, "opencode.json")
if _, err := os.Stat(opencodeJSON); err != nil {
Expand Down Expand Up @@ -1437,8 +1447,9 @@ func TestOpenCodeRenderer_InstallWorkflow_SharedVariantSuffixStripping(t *testin
t.Fatalf("InstallWorkflow: %v", err)
}

// OpenCode installs _shared/ under .opencode/sdd-orchestrator/_shared/.
installedShared := filepath.Join(workspaceDir, "sdd-orchestrator", "_shared")
// OpenCode installs _shared/ at the workflow-namespaced top-level path
// .opencode/<workflow-name>/_shared/.
installedShared := filepath.Join(workspaceDir, wf.Metadata.Name, "_shared")

// launch-templates.md must exist with opencode content (from launch-templates.opencode.md).
ltPath := filepath.Join(installedShared, "launch-templates.md")
Expand Down
Loading