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
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>/`, 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.
Expand Down
12 changes: 6 additions & 6 deletions docs/linking.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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**

Expand All @@ -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.
5 changes: 2 additions & 3 deletions docs/projections/antigravity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

7 changes: 3 additions & 4 deletions docs/projections/claude.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/projections/codex.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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.

6 changes: 4 additions & 2 deletions docs/projections/cursor.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion internal/link/antigravity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 12 additions & 9 deletions internal/link/block.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package link

import (
"fmt"
"os"
"path/filepath"
"strings"
)

Expand All @@ -10,14 +11,16 @@ const (
markerEnd = "<!-- MUSTER_LINK_END -->"
)

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 {
Expand Down
25 changes: 22 additions & 3 deletions internal/link/block_test.go
Original file line number Diff line number Diff line change
@@ -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<!-- MUSTER_POLICY -->\n<!-- MUSTER_ADAPTER -->\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") {
Expand Down
2 changes: 1 addition & 1 deletion internal/link/claude_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion internal/link/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
129 changes: 117 additions & 12 deletions internal/link/cursor.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 1 addition & 2 deletions internal/link/link.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package link

import (
"errors"
"fmt"
"os"

Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading