diff --git a/docs/architecture.md b/docs/architecture.md index e945793..a23b270 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,9 +6,9 @@ muster is a **policy distribution and link** tool for interactive coding-agent h | Piece | Role | |-------|------| -| Embedded kit (`kit/policy/`, `kit/link/`) | Prose orchestration and worker behavior for harness sessions | +| Embedded kit (`kit/policy/`, `kit/link/`) | Shared orchestration policy, concise harness adapters, and native prompt templates | | `muster install` | Copy kit to `$MUSTER_HOME/kit//`, symlink `current` | -| `muster link` | Symlink harness entrypoints and set default orchestrator selectors | +| `muster link` | Compose shared policy with each adapter, render native prompts, and set default orchestrator selectors | | `muster doctor` | Kit path, link manifest, optional harness CLI probe | The native harness handles task assignment, worker handoffs, concurrency, worktrees, and event waits. Muster does not define a task/result schema or run worker processes. Codex workers use Luna Medium; other harnesses use their native model settings. diff --git a/docs/linking.md b/docs/linking.md index 8081511..ddbd46a 100644 --- a/docs/linking.md +++ b/docs/linking.md @@ -1,6 +1,6 @@ # Linking harnesses -`muster install` lays down the versioned **kit** (policy and harness entrypoints). **`muster link`** connects those entrypoints to harness-wide user configuration and selects Muster as the default orchestrator where the harness supports that setting. Link and unlink never write into the current repository. +`muster install` lays down the versioned **kit** (shared policy, small harness adapters, and native templates). **`muster link`** composes the shared policy with each adapter, renders the native orchestrator instructions, and connects them to harness-wide user configuration. Link and unlink never write into the current repository. ## Behavior @@ -19,10 +19,10 @@ State for successfully linked harnesses is recorded in `$MUSTER_HOME/link-manife |----|------------------------|-------------------| | `claude` | `~/.claude/settings.json` → `"agent": "muster-orchestrator"` | `~/.claude/agents/muster-orchestrator.md` | | `codex` | `~/.codex/AGENTS.md` (loaded for every Codex run) | `~/.codex/agents/muster-worker.toml` | -| `cursor` | Add Muster policy to User Rules in Cursor Settings | Manual setup; the documented interface is Settings, with no supported CLI or file interface for configuring global User Rules | +| `cursor` | User-scoped local plugin discovered from `~/.cursor/plugins/local/` | `~/.cursor/plugins/local/muster-orchestrator/` (always-applied global rule) | | `antigravity` (`agy`) | `~/.gemini/antigravity-cli/settings.json` → `"agent": "muster-orchestrator"` | `~/.gemini/config/agents/muster-orchestrator/agent.md`, `muster-worker/agent.md` | -Codex's worker agent uses `gpt-6-luna` with `medium` reasoning effort. Muster reports Cursor as skipped because it cannot configure Cursor's global User Rules through a supported interface. To use Muster in Cursor, copy the contents of `muster kit path` + `/policy/orchestrator.md` into Cursor Settings → Rules → User Rules. Cursor's worker and event behavior depends on the capabilities exposed by the active session. +Claude Code and Antigravity point to rendered orchestrator agents under the installed kit's `rendered/` directory. Codex receives the rendered prompt in its managed global `AGENTS.md` block. Cursor receives a rendered user-scoped local plugin rule. The source of truth is `kit/policy/orchestrator.md` plus one short adapter in `kit/policy/adapters/` per harness. Codex's worker agent uses `gpt-6-luna` with `medium` reasoning effort. Cursor discovers the generated local plugin after a restart or Developer: Reload Window. Local plugin imports can be disabled by a Cursor Enterprise administrator. Cursor's plan and to-do features are unavailable in Auto mode; worker and event behavior depends on the active session. ## Optional shims @@ -54,7 +54,7 @@ codex debug prompt-input "Summarize the active Muster instructions" **Cursor** -Confirm Muster's orchestrator policy appears under Cursor Settings → Rules → User Rules. +Confirm `~/.cursor/plugins/local/muster-orchestrator/rules/muster-orchestrator.mdc` exists, then restart Cursor or run Developer: Reload Window. The rule should appear under the installed local plugin and apply to Agent chats. **agy** @@ -64,8 +64,8 @@ jq -r '.agent' ~/.gemini/antigravity-cli/settings.json agy -p "Reply with your agent name only" --agent muster-orchestrator ``` -Re-run **`muster link`** after `muster install` upgrades the kit so symlinks target the active kit tree. +Re-run **`muster link`** after `muster install` upgrades the kit so native instructions are rendered from the active kit and symlinks target its rendered agents. ## Testing without real harnesses -Package and CLI tests use temporary homes to validate harness-wide configuration, worker definitions, preservation of existing settings, unlink behavior, and the Cursor Settings limitation. Tests never write into the caller's project directory. +Package and CLI tests use temporary homes to validate shared-policy rendering, harness adapters, harness-wide configuration, worker definitions, preservation of existing settings, unlink behavior, and the generated Cursor local plugin. Tests never write into the caller's project directory. diff --git a/docs/projections/antigravity.md b/docs/projections/antigravity.md index 3e257e3..ff2b37c 100644 --- a/docs/projections/antigravity.md +++ b/docs/projections/antigravity.md @@ -3,11 +3,10 @@ Prefer **`muster link`**: `agy --agent muster-orchestrator` via `~/.gemini/antigravity-cli/settings.json`. ```bash -export KIT="$(muster kit path)" agy agents | rg muster jq -r '.agent' ~/.gemini/antigravity-cli/settings.json -# Orchestrator policy: $KIT/policy/orchestrator.md +# Rendered from policy/orchestrator.md and policy/adapters/antigravity.md ``` -CLI binary `agy` (harness id `antigravity`). Delegate bounded work to native worker subagents per kit policy. Assignments and handoffs are ordinary conversation. +The rendered agent combines the shared policy with Antigravity's native `/plan`, `/goal`, subagent, and lifecycle-notification controls. CLI binary `agy` (harness id `antigravity`). diff --git a/docs/projections/claude.md b/docs/projections/claude.md index ba30d5b..18600d7 100644 --- a/docs/projections/claude.md +++ b/docs/projections/claude.md @@ -1,14 +1,13 @@ # Claude Code projection -Prefer **`muster link`** (see [linking.md](../linking.md)) so `~/.claude/settings.json` selects `muster-orchestrator` and agent definitions symlink into the installed kit. +Prefer **`muster link`** (see [linking.md](../linking.md)) so `~/.claude/settings.json` selects `muster-orchestrator` and its rendered definition points to the active kit. Manual check: ```bash -export KIT="$(muster kit path)" jq -r '.agent' ~/.claude/settings.json readlink ~/.claude/agents/muster-orchestrator.md -# Policy: $KIT/policy/orchestrator.md +# Rendered from policy/orchestrator.md and policy/adapters/claude.md ``` -Orchestrator delegates bounded work to **worker** subagents per `$KIT/policy/worker.md`. Keep assignments and handoffs conversational; no fixed format is required. +The rendered agent combines the shared policy with Claude Code's native `Agent`, planning, task-list, question, and completion-notification controls. Claude workers use the harness's native model settings. diff --git a/docs/projections/codex.md b/docs/projections/codex.md index a1cb23a..de79d81 100644 --- a/docs/projections/codex.md +++ b/docs/projections/codex.md @@ -1,6 +1,6 @@ # Codex projection -Use **`muster link`** to add the orchestrator policy to Codex's native global `AGENTS.md` instructions and install a native worker agent. +Use **`muster link`** to compose the shared orchestrator policy with Codex's native-control adapter and add the rendered prompt to Codex's global `AGENTS.md`. It also installs a native worker agent. Codex has no separate root-agent selector in this setup: the active Codex session is the orchestrator because native instruction discovery loads the managed global guidance. The `muster-worker` custom agent is available for delegation. @@ -10,5 +10,5 @@ readlink ~/.codex/agents/muster-worker.toml codex debug prompt-input "Summarize the active Muster instructions" ``` -The `muster-worker` agent uses `gpt-6-luna` with `medium` reasoning effort. Assignments and handoffs remain ordinary conversation. +The `muster-worker` agent uses `gpt-6-luna` with `medium` reasoning effort. The active Codex session is the orchestrator; the installed instructions combine `kit/policy/orchestrator.md` with `kit/policy/adapters/codex.md`. This keeps shared orchestration behavior in one source while limiting the adapter to Codex-specific controls and availability caveats. diff --git a/docs/projections/cursor.md b/docs/projections/cursor.md index c9e05db..a45a355 100644 --- a/docs/projections/cursor.md +++ b/docs/projections/cursor.md @@ -1,5 +1,7 @@ # Cursor Agent projection -Muster cannot configure Cursor's global User Rules through a supported CLI or file interface. Add the contents of Muster's `policy/orchestrator.md` to Cursor Settings → Rules → User Rules to make Muster the default orchestrator for Cursor. +`muster link` composes the shared policy with `kit/policy/adapters/cursor.md` and installs the rendered orchestrator as a user-scoped Cursor local plugin under `~/.cursor/plugins/local/muster-orchestrator/`. Cursor Agent resolves as `cursor-agent`. -Cursor Agent resolves as `cursor-agent`. Worker delegation and event-driven waiting depend on capabilities exposed by the active Cursor session; the projection policy asks the orchestrator to use only capabilities actually available. +After linking or upgrading, restart Cursor or run Developer: Reload Window. Cursor discovers local plugins after reload when local plugin imports are allowed; Enterprise administrators can disable local plugin imports. + +Use Cursor's native Plan mode and to-do list for substantial multi-step work when the active mode supports them. Use native subagents for independent bounded work. Cursor documents that planning and to-dos are unavailable in Auto mode; in that mode, keep a short plan in the conversation. Follow the shared policy's prose handoffs and use only the lifecycle notifications exposed by the active session. diff --git a/internal/link/antigravity.go b/internal/link/antigravity.go index bfe571a..43796b9 100644 --- a/internal/link/antigravity.go +++ b/internal/link/antigravity.go @@ -29,7 +29,7 @@ func linkAntigravityGlobal(kitDir string, dryRun bool) ([]string, error) { var touched []string orchAgent := filepath.Join(agentsRoot, orchestratorAgentName, "agent.md") - if err := linkKitFileDryRun(orchAgent, dryRun, kitDir, "agy", orchestratorAgentName, "agent.md"); err != nil { + if err := linkRenderedOrchestrator(orchAgent, kitDir, "antigravity", filepath.Join("link", "agy", orchestratorAgentName, "agent.md"), dryRun); err != nil { return nil, err } touched = append(touched, orchAgent) diff --git a/internal/link/block.go b/internal/link/block.go index d7773da..87e4aa0 100644 --- a/internal/link/block.go +++ b/internal/link/block.go @@ -1,7 +1,8 @@ package link import ( - "fmt" + "os" + "path/filepath" "strings" ) @@ -10,14 +11,16 @@ const ( markerEnd = "" ) -func orchestratorBlock(kitDir string) string { - orch := kitDir + "/policy/orchestrator.md" - return fmt.Sprintf(`%s -# Muster orchestrator (managed by muster link) - -You are the default orchestrator for this harness. Read and follow **%s**. Use native workers and ordinary conversational assignments and handoffs. Delegate bounded work, parallelize independent tasks, use gh-stacking for dependent pull requests, and add the exact @mergify queue GitHub comment after required checks are green. Wait on lifecycle events or callbacks rather than polling or sleeping, then integrate and verify end to end. Do small tasks directly when delegation adds overhead. -%s -`, markerStart, orch, markerEnd) +func orchestratorBlock(kitDir string) (string, error) { + template, err := os.ReadFile(filepath.Join(kitDir, "link", "codex", "muster-orchestrator.md")) + if err != nil { + return "", err + } + rendered, err := renderOrchestrator(kitDir, "codex", template) + if err != nil { + return "", err + } + return string(rendered), nil } func upsertManagedBlock(existing string, block string) string { diff --git a/internal/link/block_test.go b/internal/link/block_test.go index 15fe3e8..77e9852 100644 --- a/internal/link/block_test.go +++ b/internal/link/block_test.go @@ -1,24 +1,43 @@ package link import ( + "os" + "path/filepath" "strings" "testing" ) func TestUpsertManagedBlock_idempotent(t *testing.T) { - block := orchestratorBlock("/kit") + kitDir := t.TempDir() + for path, contents := range map[string]string{ + "policy/orchestrator.md": "# Shared policy\nShared delivery guidance.", + "policy/adapters/codex.md": "## Codex controls\nUse native controls.", + "link/codex/muster-orchestrator.md": markerStart + "\n\n\n" + markerEnd, + } { + fullPath := filepath.Join(kitDir, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + } + block, err := orchestratorBlock(kitDir) + if err != nil { + t.Fatal(err) + } once := upsertManagedBlock("", block) twice := upsertManagedBlock(once, block) if strings.TrimSpace(once) != strings.TrimSpace(twice) { t.Fatalf("expected idempotent upsert\nonce=%q\ntwice=%q", once, twice) } - if !contains(twice, markerStart) || !contains(twice, "/kit/policy/orchestrator.md") || !contains(twice, "ordinary conversational assignments") { + if !contains(twice, markerStart) || !contains(twice, "Shared delivery guidance.") || !contains(twice, "## Codex controls") || contains(twice, "MUSTER_POLICY") { t.Fatalf("block missing markers: %s", twice) } } func TestRemoveManagedBlock(t *testing.T) { - block := orchestratorBlock("/kit") + block := markerStart + "\nmanaged\n" + markerEnd body := upsertManagedBlock("hello\n", block) clean := removeManagedBlock(body) if contains(clean, markerStart) || !contains(clean, "hello") { diff --git a/internal/link/claude_settings.go b/internal/link/claude_settings.go index 792ebfd..5c9c882 100644 --- a/internal/link/claude_settings.go +++ b/internal/link/claude_settings.go @@ -25,7 +25,7 @@ func linkClaudeAgentWiring(claudeDir, kitDir string, dryRun bool) ([]string, err var touched []string orchPath := filepath.Join(agentsDir, orchestratorAgentName+".md") - if err := linkKitFileDryRun(orchPath, dryRun, kitDir, "claude", orchestratorAgentName+".md"); err != nil { + if err := linkRenderedOrchestrator(orchPath, kitDir, "claude", filepath.Join("link", "claude", orchestratorAgentName+".md"), dryRun); err != nil { return nil, err } touched = append(touched, orchPath) diff --git a/internal/link/codex.go b/internal/link/codex.go index aeb5932..d0315b0 100644 --- a/internal/link/codex.go +++ b/internal/link/codex.go @@ -43,7 +43,11 @@ func linkCodexGlobal(kitDir string, dryRun bool) ([]string, error) { } globalAgents := filepath.Join(dir, "AGENTS.md") - if err := patchFile(globalAgents, func(s string) string { return upsertManagedBlock(s, orchestratorBlock(kitDir)) }, dryRun); err != nil { + block, err := orchestratorBlock(kitDir) + if err != nil { + return nil, err + } + if err := patchFile(globalAgents, func(s string) string { return upsertManagedBlock(s, block) }, dryRun); err != nil { return nil, err } touched = append(touched, globalAgents) diff --git a/internal/link/cursor.go b/internal/link/cursor.go index db4ae09..3560273 100644 --- a/internal/link/cursor.go +++ b/internal/link/cursor.go @@ -1,36 +1,141 @@ package link import ( - "errors" + "bytes" + "encoding/json" "fmt" "os" "path/filepath" ) -var errManualHarnessSetup = errors.New("requires manual harness setup") +const cursorManagedMarker = "muster-managed-plugin-v1\n" -func cursorRulesDir() (string, error) { +func cursorPluginDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } - return filepath.Join(home, ".cursor", "rules"), nil + return filepath.Join(home, ".cursor", "plugins", "local", "muster-orchestrator"), nil } -func linkCursorGlobal(_ string, _ bool) ([]string, error) { - // Cursor's documented global User Rules are edited in Cursor Settings, so - // filesystem linking cannot configure this harness-wide setting. - return nil, fmt.Errorf("global User Rules must be added in Cursor Settings; no supported CLI or file interface is documented: %w", errManualHarnessSetup) +func cursorPluginFiles(kitDir string) (manifest, rule []byte, err error) { + manifest, err = os.ReadFile(filepath.Join(kitDir, "link", "cursor", "plugin.json")) + if err != nil { + return nil, nil, err + } + template, err := os.ReadFile(filepath.Join(kitDir, "link", "cursor", "rules", "muster-orchestrator.mdc")) + if err != nil { + return nil, nil, err + } + rule, err = renderOrchestrator(kitDir, "cursor", template) + if err != nil { + return nil, nil, err + } + return manifest, rule, nil +} + +func linkCursorGlobal(kitDir string, dryRun bool) ([]string, error) { + dir, err := cursorPluginDir() + if err != nil { + return nil, err + } + manifestPath := filepath.Join(dir, ".cursor-plugin", "plugin.json") + rulePath := filepath.Join(dir, "rules", "muster-orchestrator.mdc") + markerPath := filepath.Join(dir, ".muster-managed") + manifest, rule, err := cursorPluginFiles(kitDir) + if err != nil { + return nil, err + } + if info, err := os.Lstat(dir); err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("cursor plugin path exists and is not a Muster-owned directory: %s", dir) + } + marker, err := os.ReadFile(markerPath) + if err != nil || string(marker) != cursorManagedMarker { + return nil, fmt.Errorf("cursor plugin path already exists and is not managed by Muster: %s", dir) + } + } else if !os.IsNotExist(err) { + return nil, err + } + if !dryRun { + for _, path := range []string{filepath.Join(dir, ".cursor-plugin"), filepath.Join(dir, "rules")} { + if err := os.MkdirAll(path, 0o755); err != nil { + return nil, err + } + } + if err := os.WriteFile(markerPath, []byte(cursorManagedMarker), 0o644); err != nil { + return nil, err + } + if err := os.WriteFile(manifestPath, manifest, 0o644); err != nil { + return nil, err + } + if err := os.WriteFile(rulePath, rule, 0o644); err != nil { + return nil, err + } + } + return []string{manifestPath, rulePath}, nil } func unlinkCursorGlobal(dryRun bool) error { - dir, err := cursorRulesDir() + dir, err := cursorPluginDir() if err != nil { return err } - if !dryRun { - _ = removeLink(filepath.Join(dir, "muster-orchestrator.mdc")) - _ = removeLink(filepath.Join(dir, "muster-worker.mdc")) + markerPath := filepath.Join(dir, ".muster-managed") + marker, err := os.ReadFile(markerPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if string(marker) != cursorManagedMarker || dryRun { + return nil + } + for _, path := range []string{ + filepath.Join(dir, "rules", "muster-orchestrator.mdc"), + filepath.Join(dir, ".cursor-plugin", "plugin.json"), + markerPath, + } { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + for _, path := range []string{filepath.Join(dir, "rules"), filepath.Join(dir, ".cursor-plugin"), dir} { + entries, err := os.ReadDir(path) + if os.IsNotExist(err) || (err == nil && len(entries) > 0) { + continue + } + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +func verifyCursor(kitDir string, _ []string) []string { + dir, err := cursorPluginDir() + if err != nil { + return []string{err.Error()} + } + marker, err := os.ReadFile(filepath.Join(dir, ".muster-managed")) + if err != nil || string(marker) != cursorManagedMarker { + return []string{dir + ": Muster Cursor plugin is missing or unmanaged"} + } + manifest, rule, err := cursorPluginFiles(kitDir) + if err != nil { + return []string{err.Error()} + } + actualManifest, err := os.ReadFile(filepath.Join(dir, ".cursor-plugin", "plugin.json")) + if err != nil || !json.Valid(actualManifest) || !bytes.Equal(actualManifest, manifest) { + return []string{filepath.Join(dir, ".cursor-plugin", "plugin.json") + ": missing or stale"} + } + actualRule, err := os.ReadFile(filepath.Join(dir, "rules", "muster-orchestrator.mdc")) + if err != nil || !bytes.Equal(actualRule, rule) { + return []string{filepath.Join(dir, "rules", "muster-orchestrator.mdc") + ": missing or stale"} } return nil } diff --git a/internal/link/link.go b/internal/link/link.go index 75c5abc..43beb29 100644 --- a/internal/link/link.go +++ b/internal/link/link.go @@ -1,7 +1,6 @@ package link import ( - "errors" "fmt" "os" @@ -64,7 +63,7 @@ func Run(opts Options) (*Result, error) { var targets []string t, err := h.linkGlobal(kitDir, opts.DryRun) if err != nil { - if autoDetect || errors.Is(err, errManualHarnessSetup) { + if autoDetect { res.Skipped = append(res.Skipped, id+": "+err.Error()) continue } diff --git a/internal/link/link_test.go b/internal/link/link_test.go index 7ab2ea6..dcc098b 100644 --- a/internal/link/link_test.go +++ b/internal/link/link_test.go @@ -46,7 +46,7 @@ func TestLinkClaudeHarnessWide(t *testing.T) { if err != nil { t.Fatal(err) } - want := filepath.Join(kitDir, "link", "claude", orchestratorAgentName+".md") + want := filepath.Join(kitDir, "rendered", "claude", "muster-orchestrator.md") if target != want { t.Fatalf("agent link %s != %s", target, want) } @@ -92,17 +92,37 @@ func TestLinkGlobalCodexCursorAgy(t *testing.T) { if err != nil || !strings.Contains(string(globalAgents), markerStart) { t.Fatalf("codex global instructions: %v %s", err, globalAgents) } - if _, err := os.Stat(filepath.Join(home, ".cursor", "rules", "muster-orchestrator.mdc")); !os.IsNotExist(err) { - t.Fatalf("Cursor global rules are not a supported filesystem target; got err %v", err) + for _, required := range []string{"# Muster orchestrator policy", "## Codex controls", "Use `/goal`"} { + if !strings.Contains(string(globalAgents), required) { + t.Errorf("Codex instructions missing composed section %q", required) + } + } + cursorPlugin := filepath.Join(home, ".cursor", "plugins", "local", "muster-orchestrator") + for _, path := range []string{ + filepath.Join(cursorPlugin, ".cursor-plugin", "plugin.json"), + filepath.Join(cursorPlugin, "rules", "muster-orchestrator.mdc"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("Cursor plugin file %s: %v", path, err) + } + } + cursorRule, err := os.ReadFile(filepath.Join(cursorPlugin, "rules", "muster-orchestrator.mdc")) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"alwaysApply: true", "Use Cursor's native Plan mode", "Use a native plan or TODO list"} { + if !strings.Contains(string(cursorRule), required) { + t.Errorf("Cursor rule missing %q", required) + } } - if len(res.Skipped) != 1 || !strings.Contains(res.Skipped[0], "Cursor Settings") { - t.Fatalf("Cursor setup limitation should be reported: %+v", res.Skipped) + if len(res.Skipped) != 0 { + t.Fatalf("supported harnesses should not be skipped: %+v", res.Skipped) } manifest, err := LoadManifest() if err != nil { t.Fatal(err) } - if len(manifest.Harnesses) != 2 { + if len(manifest.Harnesses) != 3 { t.Fatalf("manifest should include only configured harnesses: %+v", manifest.Harnesses) } agyAgent := filepath.Join(home, ".gemini", "config", "agents", orchestratorAgentName, "agent.md") @@ -115,6 +135,47 @@ func TestLinkGlobalCodexCursorAgy(t *testing.T) { } } +func TestRenderedOrchestratorsComposeSharedPolicyAndHarnessAdapter(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("MUSTER_HOME", filepath.Join(home, "muster")) + kitDir, err := kit.Install() + if err != nil { + t.Fatal(err) + } + + templates := map[string]string{ + "claude": filepath.Join("link", "claude", "muster-orchestrator.md"), + "codex": filepath.Join("link", "codex", "muster-orchestrator.md"), + "cursor": filepath.Join("link", "cursor", "rules", "muster-orchestrator.mdc"), + "antigravity": filepath.Join("link", "agy", "muster-orchestrator", "agent.md"), + } + for harness, template := range templates { + t.Run(harness, func(t *testing.T) { + source, err := os.ReadFile(filepath.Join(kitDir, template)) + if err != nil { + t.Fatal(err) + } + rendered, err := renderOrchestrator(kitDir, harness, source) + if err != nil { + t.Fatal(err) + } + body := string(rendered) + if !strings.Contains(body, "# Muster orchestrator policy") || strings.Contains(body, policyMarker) || strings.Contains(body, adapterMarker) { + t.Fatalf("shared policy was not composed cleanly: %s", body) + } + if !strings.Contains(body, "## "+map[string]string{ + "claude": "Claude Code controls", + "codex": "Codex controls", + "cursor": "Cursor controls", + "antigravity": "Antigravity controls", + }[harness]) { + t.Fatalf("%s adapter missing from rendered prompt", harness) + } + }) + } +} + func TestLinkNeverWritesProjectFiles(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -162,8 +223,8 @@ func TestLinkAllWithoutGlobalPaths(t *testing.T) { if err != nil { t.Fatal(err) } - if len(res.Harnesses) != 3 || len(res.Skipped) != 1 { - t.Fatalf("expected three configured harnesses and Cursor Settings guidance, got %+v", res) + if len(res.Harnesses) != 4 || len(res.Skipped) != 0 { + t.Fatalf("expected four configured harnesses with no skipped setup, got %+v", res) } } @@ -180,8 +241,11 @@ func TestClaudeSettingsRestoreOnUnlink(t *testing.T) { } kitDir := t.TempDir() _ = os.MkdirAll(filepath.Join(kitDir, "link", "claude"), 0o755) - _ = os.WriteFile(filepath.Join(kitDir, "link", "claude", orchestratorAgentName+".md"), []byte("---\nname: x\n---\n"), 0o644) + _ = os.MkdirAll(filepath.Join(kitDir, "policy", "adapters"), 0o755) + _ = os.WriteFile(filepath.Join(kitDir, "link", "claude", orchestratorAgentName+".md"), []byte("---\nname: x\n---\n\n\n"), 0o644) _ = os.WriteFile(filepath.Join(kitDir, "link", "claude", workerAgentName+".md"), []byte("---\nname: y\n---\n"), 0o644) + _ = os.WriteFile(filepath.Join(kitDir, "policy", "orchestrator.md"), []byte("# Shared policy\n"), 0o644) + _ = os.WriteFile(filepath.Join(kitDir, "policy", "adapters", "claude.md"), []byte("# Claude adapter\n"), 0o644) if _, err := linkClaudeAgentWiring(claudeDir, kitDir, false); err != nil { t.Fatal(err) } diff --git a/internal/link/render.go b/internal/link/render.go new file mode 100644 index 0000000..a3087ce --- /dev/null +++ b/internal/link/render.go @@ -0,0 +1,79 @@ +package link + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + policyMarker = "" + adapterMarker = "" +) + +func renderOrchestrator(kitDir, harness string, template []byte) ([]byte, error) { + policy, err := os.ReadFile(filepath.Join(kitDir, "policy", "orchestrator.md")) + if err != nil { + return nil, err + } + adapter, err := os.ReadFile(filepath.Join(kitDir, "policy", "adapters", harness+".md")) + if err != nil { + return nil, err + } + if strings.Count(string(template), policyMarker) != 1 || strings.Count(string(template), adapterMarker) != 1 { + return nil, fmt.Errorf("%s orchestrator template must contain exactly one policy marker and one adapter marker", harness) + } + rendered := strings.Replace(string(template), policyMarker, strings.TrimSpace(string(policy)), 1) + rendered = strings.Replace(rendered, adapterMarker, strings.TrimSpace(string(adapter)), 1) + return []byte(rendered), nil +} + +func renderOrchestratorFile(kitDir, harness, templateRel string, dryRun bool) (string, error) { + template, err := os.ReadFile(filepath.Join(kitDir, filepath.FromSlash(templateRel))) + if err != nil { + return "", err + } + rendered, err := renderOrchestrator(kitDir, harness, template) + if err != nil { + return "", err + } + path := filepath.Join(kitDir, "rendered", harness, "muster-orchestrator.md") + if !dryRun { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(path, rendered, 0o644); err != nil { + return "", err + } + } + return path, nil +} + +func verifyRenderedOrchestrator(kitDir, harness, templateRel string) string { + path := filepath.Join(kitDir, "rendered", harness, "muster-orchestrator.md") + template, err := os.ReadFile(filepath.Join(kitDir, filepath.FromSlash(templateRel))) + if err != nil { + return err.Error() + } + want, err := renderOrchestrator(kitDir, harness, template) + if err != nil { + return err.Error() + } + got, err := os.ReadFile(path) + if err != nil || string(got) != string(want) { + return path + ": missing or stale" + } + return "" +} + +func linkRenderedOrchestrator(dest, kitDir, harness, templateRel string, dryRun bool) error { + path, err := renderOrchestratorFile(kitDir, harness, templateRel, dryRun) + if err != nil { + return err + } + if dryRun { + return nil + } + return replaceWithSymlink(dest, path) +} diff --git a/internal/link/verify.go b/internal/link/verify.go index f0103e2..d26941d 100644 --- a/internal/link/verify.go +++ b/internal/link/verify.go @@ -36,6 +36,10 @@ func verifyHarness(id, kitDir string, targets []string) []string { func verifySymlinkToKit(path, kitDir string, parts ...string) string { want := kitLinkFile(kitDir, parts...) + return verifySymlinkToTarget(path, want) +} + +func verifySymlinkToTarget(path, want string) string { if _, err := os.Lstat(path); err != nil { return path + ": missing" } @@ -56,7 +60,10 @@ func verifyClaude(kitDir string) []string { } dir := filepath.Join(home, ".claude") var issues []string - if msg := verifySymlinkToKit(filepath.Join(dir, "agents", orchestratorAgentName+".md"), kitDir, "claude", orchestratorAgentName+".md"); msg != "" { + if msg := verifySymlinkToTarget(filepath.Join(dir, "agents", orchestratorAgentName+".md"), filepath.Join(kitDir, "rendered", "claude", "muster-orchestrator.md")); msg != "" { + issues = append(issues, msg) + } + if msg := verifyRenderedOrchestrator(kitDir, "claude", filepath.Join("link", "claude", orchestratorAgentName+".md")); msg != "" { issues = append(issues, msg) } data, err := os.ReadFile(filepath.Join(dir, "settings.json")) @@ -77,17 +84,15 @@ func verifyCodex(kitDir string, targets []string) []string { if msg := verifySymlinkToKit(globalAgent, kitDir, "codex", workerAgentName+".config.toml"); msg != "" { issues = append(issues, msg) } - if data, err := os.ReadFile(globalInstructions); err != nil || !strings.Contains(string(data), markerStart) { + block, blockErr := orchestratorBlock(kitDir) + data, err := os.ReadFile(globalInstructions) + if blockErr != nil || err != nil || !strings.Contains(string(data), block) { issues = append(issues, globalInstructions+": muster orchestrator instructions missing") } } return issues } -func verifyCursor(_ string, _ []string) []string { - return []string{"cursor: global User Rules must be configured in Cursor Settings; Muster cannot configure them through a supported CLI or file interface"} -} - func hasTarget(targets []string, want string) bool { for _, target := range targets { if target == want { @@ -103,7 +108,10 @@ func verifyAgy(kitDir string) []string { return []string{err.Error()} } var issues []string - if msg := verifySymlinkToKit(filepath.Join(root, orchestratorAgentName, "agent.md"), kitDir, "agy", orchestratorAgentName, "agent.md"); msg != "" { + if msg := verifySymlinkToTarget(filepath.Join(root, orchestratorAgentName, "agent.md"), filepath.Join(kitDir, "rendered", "antigravity", "muster-orchestrator.md")); msg != "" { + issues = append(issues, msg) + } + if msg := verifyRenderedOrchestrator(kitDir, "antigravity", filepath.Join("link", "agy", orchestratorAgentName, "agent.md")); msg != "" { issues = append(issues, msg) } settingsDir, err := agySettingsDir() diff --git a/kit/embed_test.go b/kit/embed_test.go index 21397fe..4a8c36f 100644 --- a/kit/embed_test.go +++ b/kit/embed_test.go @@ -9,6 +9,15 @@ func TestEmbedPolicy(t *testing.T) { for _, name := range []string{ "policy/orchestrator.md", "policy/worker.md", + "policy/adapters/claude.md", + "policy/adapters/codex.md", + "policy/adapters/antigravity.md", + "policy/adapters/cursor.md", + "link/codex/muster-orchestrator.md", + "link/claude/muster-orchestrator.md", + "link/agy/muster-orchestrator/agent.md", + "link/cursor/plugin.json", + "link/cursor/rules/muster-orchestrator.mdc", } { if _, err := Source.ReadFile(name); err != nil { t.Fatalf("missing %s: %v", name, err) diff --git a/kit/link/agy/muster-orchestrator/agent.md b/kit/link/agy/muster-orchestrator/agent.md index 31190c2..92a574d 100644 --- a/kit/link/agy/muster-orchestrator/agent.md +++ b/kit/link/agy/muster-orchestrator/agent.md @@ -5,4 +5,6 @@ description: Default Muster orchestrator. Delegate bounded work in parallel, int # Muster orchestrator -Read and follow `/policy/orchestrator.md`, where `` is the output of `muster kit path`. Use Antigravity's native subagents, worktrees, and lifecycle events. Keep coordination small; parallelize independent work and verify the integrated result. Do small tasks directly when delegation adds overhead. + + + diff --git a/kit/link/claude/muster-orchestrator.md b/kit/link/claude/muster-orchestrator.md index 9dd9074..8a6124e 100644 --- a/kit/link/claude/muster-orchestrator.md +++ b/kit/link/claude/muster-orchestrator.md @@ -1,9 +1,11 @@ --- name: muster-orchestrator description: Default Muster orchestrator. Delegate bounded work to native worker subagents, parallelize independent tasks, integrate, and verify. -tools: Bash, Read, Grep, Glob, Skill, AskUserQuestion, Task +tools: Bash, Read, Grep, Glob, Skill, AskUserQuestion, Agent, EnterPlanMode, ExitPlanMode, TodoWrite --- # Muster orchestrator -Read and follow `/policy/orchestrator.md`, where `` is the output of `muster kit path`. Use Claude Code's native subagents, worktrees, and lifecycle events. Keep coordination small; delegate bounded work and independent tasks in parallel, then review and verify integrated changes. Do small tasks directly when delegation adds overhead. + + + diff --git a/kit/link/codex/muster-orchestrator.md b/kit/link/codex/muster-orchestrator.md new file mode 100644 index 0000000..fd8fef9 --- /dev/null +++ b/kit/link/codex/muster-orchestrator.md @@ -0,0 +1,7 @@ + +# Muster orchestrator (managed by muster link) + + + + + diff --git a/kit/link/cursor/plugin.json b/kit/link/cursor/plugin.json new file mode 100644 index 0000000..f415883 --- /dev/null +++ b/kit/link/cursor/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "muster-orchestrator", + "version": "1.0.0", + "description": "Muster's harness-wide orchestrator policy for Cursor Agent.", + "author": { + "name": "Muster" + } +} diff --git a/kit/link/cursor/rules/muster-orchestrator.mdc b/kit/link/cursor/rules/muster-orchestrator.mdc new file mode 100644 index 0000000..d639090 --- /dev/null +++ b/kit/link/cursor/rules/muster-orchestrator.mdc @@ -0,0 +1,10 @@ +--- +description: Muster's default orchestrator policy for Cursor Agent. +alwaysApply: true +--- + +# Muster orchestrator for Cursor Agent + + + + diff --git a/kit/policy/adapters/antigravity.md b/kit/policy/adapters/antigravity.md new file mode 100644 index 0000000..c5962fd --- /dev/null +++ b/kit/policy/adapters/antigravity.md @@ -0,0 +1,5 @@ +## Antigravity controls + +- Use `/plan` for substantial or ambiguous work that benefits from a reviewable plan. Use `/goal` only for an explicitly continuing objective with evidence-based completion criteria. +- Delegate bounded parallel work to native subagents and continue independently while background work runs. Let lifecycle notifications deliver results; use `/tasks` to inspect a task when needed, not as a polling loop. +- Ask a focused question only when a material decision blocks progress. Do not use timer-based commands to wait for workers or CI. diff --git a/kit/policy/adapters/claude.md b/kit/policy/adapters/claude.md new file mode 100644 index 0000000..7c38b53 --- /dev/null +++ b/kit/policy/adapters/claude.md @@ -0,0 +1,6 @@ +## Claude Code controls + +- Delegate bounded work to native `Agent` subagents. Run independent assignments concurrently, keep ownership distinct, and integrate their completion notifications. Do not create an experimental agent team for ordinary orchestration. +- Use `EnterPlanMode` and `ExitPlanMode` when substantial, ambiguous, or high-risk work benefits from a reviewable plan. Use `TodoWrite` for the orchestrator's short list of substantial steps, not as a worker contract or required artifact. +- Use `AskUserQuestion` only when a material decision blocks progress. Make low-impact assumptions and continue when safe. +- Use `/goal ` only for an explicitly continuing task with concrete evidence and a bounded stop condition. Do not use `/loop` or repeated `/tasks` checks to wait for an agent, command, or CI event. Let Claude Code deliver background completion notifications; use `/tasks` to inspect a task when needed. diff --git a/kit/policy/adapters/codex.md b/kit/policy/adapters/codex.md new file mode 100644 index 0000000..e200fae --- /dev/null +++ b/kit/policy/adapters/codex.md @@ -0,0 +1,6 @@ +## Codex controls + +- Use Codex's native plan or task-tracking controls when the active session exposes them. Plan mode shapes the approach before risky or broad changes; do not create TODO files to imitate a missing tracker. +- Use `/goal` only when the user explicitly wants work to continue across turns, with a verifiable outcome and constraints. A Goal does not replace worker decomposition or evidence checks. +- Delegate bounded implementation and investigation through the native subagent or worker controls exposed by the active Codex session. Keep assignments and handoffs in ordinary conversation. +- Ask the user directly only when a material decision blocks progress; Codex has no portable `AskUserQuestion` tool. Wait on native worker lifecycle events or callbacks instead of polling or sleeping. Name controls only when the active Codex surface exposes them. diff --git a/kit/policy/adapters/cursor.md b/kit/policy/adapters/cursor.md new file mode 100644 index 0000000..d08451e --- /dev/null +++ b/kit/policy/adapters/cursor.md @@ -0,0 +1,5 @@ +## Cursor controls + +- Use Cursor's native Plan mode and to-do list for substantial multi-step work when the active mode supports them. +- Use native subagents for independent bounded work, with distinct ownership. +- In Auto mode, where planning and to-dos are unavailable, keep a short plan in the conversation. Ask direct questions only when a material decision blocks progress. diff --git a/kit/policy/orchestrator.md b/kit/policy/orchestrator.md index ff2e1f2..9e7b31d 100644 --- a/kit/policy/orchestrator.md +++ b/kit/policy/orchestrator.md @@ -8,6 +8,14 @@ You are the default orchestrator for this harness session. Use its native worker - Do the least implementation needed in the orchestrator session. Handle a task directly when it is small enough that delegation would add more overhead than value, or when the harness cannot delegate. - Keep the user's goal and decisions in the main context. Give each worker only the context needed to act. Do not ask the user to restate the workflow; ask only when a material decision or missing requirement blocks progress. +## Use native planning and continuation controls + +- Use a native plan or TODO list for substantial work with multiple steps or dependencies. Keep it brief, update it when work starts, finishes, or becomes blocked, and use it as coordination state rather than a required file or worker handoff format. Skip it for simple tasks. +- Use a persistent goal or continuation mode only when the user asks for work to continue across turns, or one is already active. Define the outcome, evidence that proves it, important constraints, and the condition for stopping as blocked. A goal is a completion contract, not permission to run forever. +- Iterate from evidence: inspect results, choose the next useful action, and check again. Do not use timed loops, polling, or sleeps to wait for workers, CI, or external events. Prefer harness lifecycle events, callbacks, or subscriptions. Keep making independent progress while a worker or watcher waits. +- Ask through the harness's native question interface when an answer changes scope, behavior, a consequential choice, or is required to continue safely. Group related questions into one concise ask. Otherwise make a reasonable, low-impact assumption and continue. Do not ask about decisions already made or permissions already granted. +- These controls are harness-specific. Use the active harness's native feature when it is available; do not assume every harness exposes the same command, tool, or task state. Never create a planning file just to simulate a missing native feature. + ## Decompose and delegate - For substantial work, split the goal into bounded tasks with distinct outcomes and clear file or component ownership. State dependencies, relevant context, completion expectations, verification, and out-of-scope work in ordinary prose. Adapt the detail to the task; there is no fixed task format.