diff --git a/.github/workflows/verify-docs.yml b/.github/workflows/verify-docs.yml new file mode 100644 index 00000000..c58a1a25 --- /dev/null +++ b/.github/workflows/verify-docs.yml @@ -0,0 +1,23 @@ +name: Verify Docs + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + verify-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Verify extension docs are in sync with code + run: go run ./cmd/verify-docs diff --git a/CLAUDE.md b/CLAUDE.md index d6e1fe46..bedc1e9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,8 +50,8 @@ MULTICLAUDE_TEST_MODE=1 go test ./test/... # Skip Claude startup │ Daemon (internal/daemon) │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Health │ │ Message │ │ Wake/ │ │ Socket │ │ -│ │ Check │ │ Router │ │ Nudge │ │ Server │ │ -│ │ (2min) │ │ (2min) │ │ (2min) │ │ │ │ +│ │ Check │ │ (2min) │ │ (2min) │ │ Server │ │ +│ │ (2min) │ │ Router │ │ Nudge │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ └────────────────────────────────┬────────────────────────────────┘ │ @@ -212,6 +212,29 @@ External tools can integrate via: **Note:** Web UIs, event hooks, and notification systems are explicitly out of scope per ROADMAP.md. +### For LLMs: Keeping Extension Docs Updated + +**CRITICAL:** When modifying multiclaude core, keep extension documentation **code-first and drift-free**. Never document an API/command/event that does not exist on `main`. If something is planned, mark it `[PLANNED]` until it ships. Always run `go run ./cmd/verify-docs` on doc or API changes. + +1. **State Schema Changes** (`internal/state/state.go`) + - Update: [`docs/extending/STATE_FILE_INTEGRATION.md`](docs/extending/STATE_FILE_INTEGRATION.md) + - Update schema reference section + - Update all code examples showing state structure + - Run: `go run ./cmd/verify-docs` + +2. **Socket Command Changes** (`internal/daemon/daemon.go`) + - Update: [`docs/extending/SOCKET_API.md`](docs/extending/SOCKET_API.md) + - Add/update command reference entries + - Add code examples for new commands + - Update client library examples if needed + +**Pattern:** After any internal/* or pkg/* changes, search extension docs for outdated references: +```bash +# Find docs that might need updating +grep -r "internal/state" docs/extending/ +grep -r "socket.Request" docs/extending/ +``` + ## Contributing Checklist When modifying agent behavior: diff --git a/docs/CLI_RESTRUCTURE_PROPOSAL.md b/docs/CLI_RESTRUCTURE_PROPOSAL.md new file mode 100644 index 00000000..6d66705e --- /dev/null +++ b/docs/CLI_RESTRUCTURE_PROPOSAL.md @@ -0,0 +1,323 @@ +# CLI Restructure Proposal + +> **Status**: Draft proposal for discussion +> **Author**: cool-wolf worker +> **Aligns with**: ROADMAP P2 - Better onboarding + +## Problem Statement + +The multiclaude CLI has grown organically and now suffers from: + +1. **Too many top-level commands** (28 entries) +2. **Redundant aliases** that pollute `--help` output +3. **Inconsistent naming** (`agent`/`agents`, `work`/`worker`) +4. **Unclear mental model** - is the tool repo-centric or agent-centric? + +### Evidence: Current `--help` Output + +``` +Subcommands: + repair ← maintenance + claude ← agent context + logs ← agent ops + status ← system overview + daemon ← daemon management + worker ← agent creation + work ← ALIAS for worker + agent ← agent ops + attach ← ALIAS for agent attach + review ← agent creation + config ← repo config + start ← ALIAS for daemon start + list ← ALIAS for repo list + workspace ← agent creation + refresh ← agent ops + docs ← meta + diagnostics ← meta + version ← meta + agents ← agent definitions (confusing: singular vs plural) + init ← ALIAS for repo init + cleanup ← maintenance + bug ← meta + stop-all ← daemon control + repo ← repo management + history ← ALIAS for repo history + message ← agent comms +``` + +A new user sees 28 commands and has no idea where to start. + +## Current Command Tree (Actual) + +``` +multiclaude +├── daemon +│ ├── start +│ ├── stop +│ ├── status +│ └── logs +├── repo +│ ├── init +│ ├── list +│ ├── rm +│ ├── use +│ ├── current +│ ├── unset +│ ├── history +│ └── hibernate +├── worker +│ ├── create (default) +│ ├── list +│ └── rm +├── workspace +│ ├── add +│ ├── rm +│ ├── list +│ └── connect (default) +├── agent +│ ├── attach +│ ├── complete +│ ├── restart +│ ├── send-message (alias) +│ ├── list-messages (alias) +│ ├── read-message (alias) +│ └── ack-message (alias) +├── agents +│ ├── list +│ ├── spawn +│ └── reset +├── message +│ ├── send +│ ├── list +│ ├── read +│ └── ack +├── logs +│ ├── list +│ ├── search +│ └── clean +├── start (alias → daemon start) +├── init (alias → repo init) +├── list (alias → repo list) +├── history (alias → repo history) +├── attach (alias → agent attach) +├── work (alias → worker) +├── status +├── stop-all +├── cleanup +├── repair +├── refresh +├── claude +├── review +├── config +├── docs +├── diagnostics +├── version +└── bug +``` + +**Issues by category:** + +| Issue | Count | Examples | +|-------|-------|----------| +| Top-level aliases | 7 | `start`, `init`, `list`, `history`, `attach`, `work` | +| Nested aliases | 4 | `agent send-message` → `message send` | +| Singular/plural confusion | 1 | `agent` vs `agents` | +| Unclear grouping | 5 | `logs`, `refresh`, `status`, `claude`, `review` | + +## Proposed Solutions + +### Option A: Documentation-Only (Minimal Change) + +**Change**: Improve help text and docs, no code changes. + +**Approach**: +1. Rewrite `--help` to show "Getting Started" section first +2. Group commands visually in help output +3. Add `multiclaude quickstart` command that shows common workflows +4. Update COMMANDS.md with clearer structure + +**Pros**: No breaking changes, fast to implement +**Cons**: Still confusing command surface, doesn't fix root cause + +### Option B: Deprecation Warnings (Medium Change) + +**Change**: Add deprecation warnings to aliases, document preferred commands. + +**Approach**: +1. Aliases print `DEPRECATED: Use 'multiclaude repo init' instead` +2. Hide aliases from `--help` output (still work, just not shown) +3. Document migration path in COMMANDS.md +4. Remove aliases in v2.0 + +**Pros**: Gradual migration, preserves backward compat +**Cons**: Two releases needed, some user friction + +### Option C: Restructure Verbs (Breaking Change) + +**Change**: Consolidate commands under clear noun groups. + +**Proposed structure**: +``` +multiclaude +├── daemon (start, stop, status, logs) +├── repo (init, list, rm, use, config, history, hibernate) +├── agent (create, list, rm, attach, restart, complete) ← merges worker+workspace +├── message (send, list, read, ack) +├── logs (view, list, search, clean) +├── status ← comprehensive overview +├── refresh ← sync all worktrees +├── cleanup ← maintenance +├── repair ← maintenance +├── version +├── help ← enhanced help +``` + +**Key changes**: +- Merge `worker`, `workspace`, `agents` under `agent` +- Remove all top-level aliases +- `agent create "task"` replaces `worker create` +- `agent create --workspace` replaces `workspace add` + +**Pros**: Clean, learnable, consistent +**Cons**: Breaking change, migration required + +### Option D: Hybrid (Recommended) + +**Change**: Implement Option B now, plan Option C for v2.0. + +**Phase 1 (Now)**: +1. Hide aliases from `--help` (still work) +2. Group help output by category +3. Add `multiclaude guide` command with interactive walkthrough +4. Rename `agents` → `templates` (avoids `agent`/`agents` confusion) + +**Phase 2 (v2.0)**: +1. Remove deprecated aliases +2. Optionally merge `worker`/`workspace` under `agent` + +## Recommended Immediate Actions + +### 1. Improve Help Output + +Current: +``` +Subcommands: + repair Repair state after crash + claude Restart Claude in current agent context + ... +``` + +Proposed: +``` +Multiclaude - orchestrate multiple Claude Code agents + +QUICK START: + multiclaude repo init Initialize a repository + multiclaude worker "task" Create a worker for a task + multiclaude status See what's running + +DAEMON: + daemon start/stop/status/logs Manage background process + +REPOSITORIES: + repo init/list/rm/use/history Track and manage repos + +AGENTS: + worker create/list/rm Task-focused workers + workspace add/list/rm/connect Persistent workspaces + agent attach/restart/complete Agent operations + +COMMUNICATION: + message send/list/read/ack Inter-agent messaging + +MAINTENANCE: + cleanup, repair, refresh Fix and sync state + logs, config, diagnostics Inspect and configure + +Run 'multiclaude --help' for details. +``` + +### 2. Add `guide` Command + +```bash +$ multiclaude guide + +Welcome to multiclaude! Here's how to get started: + +1. INITIALIZE A REPO + multiclaude repo init https://github.com/you/repo + +2. START THE DAEMON + multiclaude start + +3. CREATE A WORKER + multiclaude worker "Fix the login bug" + +4. WATCH IT WORK + multiclaude agent attach + +Need more? See: multiclaude docs +``` + +### 3. Hide Aliases from Help + +In `cli.go`, add a `Hidden` field to Command: + +```go +type Command struct { + Name string + Description string + Hidden bool // Don't show in --help + ... +} + +// Mark aliases as hidden +c.rootCmd.Subcommands["init"] = repoCmd.Subcommands["init"] +c.rootCmd.Subcommands["init"].Hidden = true +``` + +### 4. Rename `agents` → `templates` + +The current naming creates confusion: +- `agent attach` - operate on running agent +- `agents list` - list agent definitions (templates) + +Rename to: +- `templates list` - list agent templates +- `templates spawn` - spawn from template +- `templates reset` - reset to defaults + +## Migration Path + +| Current | Deprecated In | Removed In | Replacement | +|---------|---------------|------------|-------------| +| `multiclaude init` | v1.x | v2.0 | `multiclaude repo init` | +| `multiclaude list` | v1.x | v2.0 | `multiclaude repo list` | +| `multiclaude start` | v1.x | v2.0 | `multiclaude daemon start` | +| `multiclaude attach` | v1.x | v2.0 | `multiclaude agent attach` | +| `multiclaude work` | v1.x | v2.0 | `multiclaude worker` | +| `multiclaude history` | v1.x | v2.0 | `multiclaude repo history` | +| `multiclaude agents` | v1.x | v2.0 | `multiclaude templates` | + +## Implementation Checklist + +- [ ] Add `Hidden` field to Command struct +- [ ] Mark aliases as hidden +- [ ] Restructure help output with categories +- [ ] Add `guide` command +- [ ] Rename `agents` → `templates` +- [ ] Update COMMANDS.md +- [ ] Update embedded prompts +- [ ] Add deprecation warnings to aliases +- [ ] Update tests + +## Questions for Review + +1. **Keep `start` alias?** It's the most commonly used shortcut. +2. **Merge worker/workspace?** They're conceptually similar (agent instances). +3. **Add `quickstart` or `guide`?** Which name is clearer? +4. **Timeline for v2.0?** When do we remove deprecated aliases? + +--- + +*Generated by cool-wolf worker analyzing CLI structure.* diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1e886067..7b08c633 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1,13 +1,22 @@ # Commands Reference -Everything you can tell multiclaude to do. +Everything you can tell multiclaude to do. Run `multiclaude` with no arguments for a quick reference. + +## Quick Start + +```bash +multiclaude repo init # Track a repository +multiclaude start # Start the daemon (alias for daemon start) +multiclaude worker "task" # Create a worker for a task +multiclaude status # See what's running +``` ## Daemon The daemon is the brain. Start it, and agents come alive. ```bash -multiclaude start # Wake up +multiclaude daemon start # Wake up multiclaude daemon stop # Go to sleep multiclaude daemon status # You alive? multiclaude daemon logs -f # What are you thinking? diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3e06628b..0640601d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "runtime/debug" + "sort" "strconv" "strings" "time" @@ -73,11 +74,14 @@ func IsDevVersion() bool { // Command represents a CLI command type Command struct { - Name string - Description string - Usage string - Run func(args []string) error - Subcommands map[string]*Command + Name string + Description string + LongDescription string // Detailed help text shown with --help + Usage string + Run func(args []string) error + Subcommands map[string]*Command + Hidden bool // Don't show in --help (for aliases) + Category string // For grouping in help output } // CLI manages the command-line interface @@ -273,21 +277,77 @@ func (c *CLI) executeCommand(cmd *Command, args []string) error { func (c *CLI) showHelp() error { fmt.Println("multiclaude - repo-centric orchestrator for Claude Code") fmt.Println() - fmt.Println("Usage: multiclaude [options]") + fmt.Println("QUICK START:") + fmt.Println(" repo init Track a GitHub repository") + fmt.Println(" start Start the daemon") + fmt.Println(" worker \"task\" Create a worker for a task") + fmt.Println(" status See what's running") fmt.Println() - fmt.Println("Commands:") + + // Define category order and labels + categories := []struct { + key string + label string + }{ + {"daemon", "DAEMON:"}, + {"repo", "REPOSITORIES:"}, + {"agent", "AGENTS:"}, + {"comm", "COMMUNICATION:"}, + {"maint", "MAINTENANCE:"}, + {"meta", "META:"}, + } + + // Group commands by category + byCategory := make(map[string][]*struct { + name string + cmd *Command + }) for name, cmd := range c.rootCmd.Subcommands { - fmt.Printf(" %-15s %s\n", name, cmd.Description) + if cmd.Hidden || strings.HasPrefix(name, "_") { + continue + } + cat := cmd.Category + if cat == "" { + cat = "meta" // Default category + } + byCategory[cat] = append(byCategory[cat], &struct { + name string + cmd *Command + }{name, cmd}) } - fmt.Println() - fmt.Println("Use 'multiclaude --help' for more information about a command.") + // Sort commands within each category + for _, cmds := range byCategory { + sort.Slice(cmds, func(i, j int) bool { + return cmds[i].name < cmds[j].name + }) + } + + // Print by category + for _, cat := range categories { + cmds := byCategory[cat.key] + if len(cmds) == 0 { + continue + } + fmt.Println(cat.label) + for _, item := range cmds { + fmt.Printf(" %-15s %s\n", item.name, item.cmd.Description) + } + fmt.Println() + } + + fmt.Println("Run 'multiclaude --help' for details.") return nil } // showCommandHelp shows help for a specific command func (c *CLI) showCommandHelp(cmd *Command) error { + // If this is the root command, use the categorized help + if cmd == c.rootCmd { + return c.showHelp() + } + fmt.Printf("%s - %s\n", cmd.Name, cmd.Description) fmt.Println() if cmd.Usage != "" { @@ -295,11 +355,16 @@ func (c *CLI) showCommandHelp(cmd *Command) error { fmt.Println() } + if cmd.LongDescription != "" { + fmt.Println(cmd.LongDescription) + fmt.Println() + } + if len(cmd.Subcommands) > 0 { fmt.Println("Subcommands:") for name, subcmd := range cmd.Subcommands { - // Skip internal commands (prefixed with _) - if strings.HasPrefix(name, "_") { + // Skip internal commands (prefixed with _) and hidden commands + if strings.HasPrefix(name, "_") || subcmd.Hidden { continue } fmt.Printf(" %-15s %s\n", name, subcmd.Description) @@ -319,6 +384,8 @@ func (c *CLI) registerCommands() { Description: "Start the daemon (alias for 'daemon start')", Usage: "multiclaude start", Run: c.startDaemon, + Hidden: true, // Alias - prefer 'daemon start' + Category: "daemon", } // Root-level status command - comprehensive system overview @@ -327,12 +394,14 @@ func (c *CLI) registerCommands() { Description: "Show system status overview", Usage: "multiclaude status", Run: c.systemStatus, + Category: "maint", } daemonCmd := &Command{ Name: "daemon", Description: "Manage the multiclaude daemon", Subcommands: make(map[string]*Command), + Category: "daemon", } daemonCmd.Subcommands["start"] = &Command{ @@ -377,6 +446,7 @@ func (c *CLI) registerCommands() { Description: "Stop daemon and kill all multiclaude tmux sessions", Usage: "multiclaude stop-all [--clean] [--yes]", Run: c.stopAll, + Category: "daemon", } // Repository commands (repo subcommand) @@ -384,6 +454,7 @@ func (c *CLI) registerCommands() { Name: "repo", Description: "Manage repositories", Subcommands: make(map[string]*Command), + Category: "repo", } repoCmd.Subcommands["init"] = &Command{ @@ -438,16 +509,43 @@ func (c *CLI) registerCommands() { repoCmd.Subcommands["hibernate"] = &Command{ Name: "hibernate", Description: "Hibernate a repository, archiving uncommitted changes", - Usage: "multiclaude repo hibernate [--repo ] [--all] [--yes]", - Run: c.hibernateRepo, + LongDescription: `Hibernating a repository STOPS ALL TOKEN CONSUMPTION by killing agents. + +Running agents (supervisor, merge-queue, workspace, workers) continuously +consume API tokens even when idle. Use hibernate to pause billing. + +Options: + --repo Repository to hibernate (auto-detected if in worktree) + --all Also hibernate persistent agents (supervisor, workspace) + --yes Skip confirmation prompt + +By default, only workers and review agents are hibernated. Use --all to +stop ALL agents including supervisor, merge-queue, and workspace. + +Uncommitted changes are archived to ~/.multiclaude/archives// and +can be recovered later. + +Example: + multiclaude repo hibernate --all # Stop all agents, stop token usage + multiclaude repo hibernate # Stop workers only, core agents remain`, + Usage: "multiclaude repo hibernate [--repo ] [--all] [--yes]", + Run: c.hibernateRepo, } c.rootCmd.Subcommands["repo"] = repoCmd - // Backward compatibility aliases for root-level repo commands - c.rootCmd.Subcommands["init"] = repoCmd.Subcommands["init"] - c.rootCmd.Subcommands["list"] = repoCmd.Subcommands["list"] - c.rootCmd.Subcommands["history"] = repoCmd.Subcommands["history"] + // Backward compatibility aliases for root-level repo commands (hidden) + initAlias := *repoCmd.Subcommands["init"] + initAlias.Hidden = true + c.rootCmd.Subcommands["init"] = &initAlias + + listAlias := *repoCmd.Subcommands["list"] + listAlias.Hidden = true + c.rootCmd.Subcommands["list"] = &listAlias + + historyAlias := *repoCmd.Subcommands["history"] + historyAlias.Hidden = true + c.rootCmd.Subcommands["history"] = &historyAlias // Worker commands workerCmd := &Command{ @@ -455,6 +553,7 @@ func (c *CLI) registerCommands() { Description: "Manage worker agents", Usage: "multiclaude worker [] [--repo ] [--branch ] [--push-to ]", Subcommands: make(map[string]*Command), + Category: "agent", } workerCmd.Run = c.createWorker // Default action for 'worker' command (same as 'worker create') @@ -482,8 +581,10 @@ func (c *CLI) registerCommands() { c.rootCmd.Subcommands["worker"] = workerCmd - // 'work' is an alias for 'worker' (backward compatibility) - c.rootCmd.Subcommands["work"] = workerCmd + // 'work' is an alias for 'worker' (backward compatibility, hidden) + workAlias := *workerCmd + workAlias.Hidden = true + c.rootCmd.Subcommands["work"] = &workAlias // Workspace commands workspaceCmd := &Command{ @@ -491,6 +592,7 @@ func (c *CLI) registerCommands() { Description: "Manage workspaces", Usage: "multiclaude workspace []", Subcommands: make(map[string]*Command), + Category: "agent", } workspaceCmd.Run = c.workspaceDefault // Default action: list or connect @@ -530,6 +632,7 @@ func (c *CLI) registerCommands() { Name: "agent", Description: "Agent communication commands", Subcommands: make(map[string]*Command), + Category: "agent", } // Legacy message commands (aliases for backward compatibility) @@ -591,6 +694,7 @@ func (c *CLI) registerCommands() { Name: "message", Description: "Manage inter-agent messages", Subcommands: make(map[string]*Command), + Category: "comm", } messageCmd.Subcommands["send"] = &Command{ @@ -623,8 +727,10 @@ func (c *CLI) registerCommands() { c.rootCmd.Subcommands["message"] = messageCmd - // 'attach' is an alias for 'agent attach' (backward compatibility) - c.rootCmd.Subcommands["attach"] = agentCmd.Subcommands["attach"] + // 'attach' is an alias for 'agent attach' (backward compatibility, hidden) + attachAlias := *agentCmd.Subcommands["attach"] + attachAlias.Hidden = true + c.rootCmd.Subcommands["attach"] = &attachAlias // Maintenance commands c.rootCmd.Subcommands["cleanup"] = &Command{ @@ -632,6 +738,7 @@ func (c *CLI) registerCommands() { Description: "Clean up orphaned resources", Usage: "multiclaude cleanup [--dry-run] [--verbose] [--merged]", Run: c.cleanup, + Category: "maint", } c.rootCmd.Subcommands["repair"] = &Command{ @@ -639,13 +746,15 @@ func (c *CLI) registerCommands() { Description: "Repair state after crash", Usage: "multiclaude repair [--verbose]", Run: c.repair, + Category: "maint", } c.rootCmd.Subcommands["refresh"] = &Command{ Name: "refresh", Description: "Sync agent worktrees with main branch", - Usage: "multiclaude refresh", + Usage: "multiclaude refresh [--all]", Run: c.refresh, + Category: "maint", } // Claude restart command - for resuming Claude after exit @@ -654,6 +763,7 @@ func (c *CLI) registerCommands() { Description: "Restart Claude in current agent context", Usage: "multiclaude claude", Run: c.restartClaude, + Category: "agent", } // Debug command @@ -662,6 +772,7 @@ func (c *CLI) registerCommands() { Description: "Show generated CLI documentation", Usage: "multiclaude docs", Run: c.showDocs, + Category: "meta", } // Review command @@ -670,6 +781,7 @@ func (c *CLI) registerCommands() { Description: "Spawn a review agent for a PR", Usage: "multiclaude review ", Run: c.reviewPR, + Category: "agent", } // Logs commands @@ -678,6 +790,7 @@ func (c *CLI) registerCommands() { Description: "View and manage agent output logs", Usage: "multiclaude logs [] [-f|--follow]", Subcommands: make(map[string]*Command), + Category: "maint", } logsCmd.Run = c.viewLogs // Default action: view logs for an agent @@ -711,6 +824,7 @@ func (c *CLI) registerCommands() { Description: "View or modify repository configuration", Usage: "multiclaude config [repo] [--mq-enabled=true|false] [--mq-track=all|author|assigned] [--ps-enabled=true|false] [--ps-track=all|author|assigned]", Run: c.configRepo, + Category: "repo", } // Bug report command @@ -719,6 +833,7 @@ func (c *CLI) registerCommands() { Description: "Generate a diagnostic bug report", Usage: "multiclaude bug [--output ] [--verbose] [description]", Run: c.bugReport, + Category: "meta", } // Diagnostics command @@ -727,6 +842,7 @@ func (c *CLI) registerCommands() { Description: "Show system diagnostics in machine-readable format", Usage: "multiclaude diagnostics [--json] [--output ]", Run: c.diagnostics, + Category: "meta", } // Version command @@ -735,6 +851,7 @@ func (c *CLI) registerCommands() { Description: "Show version information", Usage: "multiclaude version [--json]", Run: c.versionCommand, + Category: "meta", } // Agents command - for managing agent definitions @@ -742,6 +859,7 @@ func (c *CLI) registerCommands() { Name: "agents", Description: "Manage agent definitions", Subcommands: make(map[string]*Command), + Category: "agent", } agentsCmd.Subcommands["list"] = &Command{ @@ -891,6 +1009,9 @@ func (c *CLI) systemStatus(args []string) error { fmt.Printf(" Repos: %d\n", len(repos)) fmt.Println() + // Track total active agents for token warning + totalActiveAgents := 0 + // Show each repo with agents for _, repo := range repos { repoMap, ok := repo.(map[string]interface{}) @@ -903,10 +1024,8 @@ func (c *CLI) systemStatus(args []string) error { if v, ok := repoMap["total_agents"].(float64); ok { totalAgents = int(v) } - workerCount := 0 - if v, ok := repoMap["worker_count"].(float64); ok { - workerCount = int(v) - } + totalActiveAgents += totalAgents + sessionHealthy, _ := repoMap["session_healthy"].(bool) // Repo line @@ -916,12 +1035,31 @@ func (c *CLI) systemStatus(args []string) error { } fmt.Printf(" %s %s\n", repoStatus, format.Bold.Sprint(name)) - // Agent summary - coreAgents := totalAgents - workerCount - if coreAgents < 0 { - coreAgents = 0 + // Show core agents by name and type + if coreAgents, ok := repoMap["core_agents"].([]interface{}); ok && len(coreAgents) > 0 { + var coreNames []string + for _, ca := range coreAgents { + if caMap, ok := ca.(map[string]interface{}); ok { + agentName, _ := caMap["name"].(string) + agentType, _ := caMap["type"].(string) + coreNames = append(coreNames, fmt.Sprintf("%s (%s)", agentName, agentType)) + } + } + fmt.Printf(" Core: %s\n", strings.Join(coreNames, ", ")) + } + + // Show workers + if workerNames, ok := repoMap["worker_names"].([]interface{}); ok && len(workerNames) > 0 { + var names []string + for _, wn := range workerNames { + if name, ok := wn.(string); ok { + names = append(names, name) + } + } + fmt.Printf(" Workers: %s\n", strings.Join(names, ", ")) + } else { + fmt.Printf(" Workers: none\n") } - fmt.Printf(" Agents: %d core, %d workers\n", coreAgents, workerCount) // Show fork info if applicable if isFork, _ := repoMap["is_fork"].(bool); isFork { @@ -934,6 +1072,16 @@ func (c *CLI) systemStatus(args []string) error { } fmt.Println() + + // Token consumption warning + if totalActiveAgents > 0 { + fmt.Printf(" %s %d active agent(s) consuming API tokens\n", + format.Yellow.Sprint("⚠"), + totalActiveAgents) + format.Dimmed(" Stop token usage: multiclaude repo hibernate --all") + fmt.Println() + } + format.Dimmed("Details: multiclaude repo list | multiclaude worker list") return nil } @@ -2104,7 +2252,7 @@ func (c *CLI) createWorker(args []string) error { if hasPushTo { // --push-to requires --branch to specify the remote branch to start from if _, hasBranch := flags["branch"]; !hasBranch { - return errors.InvalidUsage("--push-to requires --branch to specify the remote branch (e.g., --branch origin/work/jolly-hawk --push-to work/jolly-hawk)") + return errors.InvalidUsage("--push-to requires --branch to specify the remote branch (e.g., --branch origin/multiclaude/jolly-hawk --push-to multiclaude/jolly-hawk)") } } @@ -2175,7 +2323,7 @@ func (c *CLI) createWorker(args []string) error { } } else { // Normal case: create a new branch for this worker - branchName = fmt.Sprintf("work/%s", workerName) + branchName = fmt.Sprintf("multiclaude/%s", workerName) fmt.Printf("Creating worktree at: %s\n", wtPath) if err := wt.CreateNewBranch(wtPath, branchName, startBranch); err != nil { return errors.WorktreeCreationFailed(err) @@ -5163,8 +5311,12 @@ func (c *CLI) localCleanup(dryRun bool, verbose bool) error { } } - // Clean up orphaned work/* and workspace/* branches - removed, issues := c.cleanupOrphanedBranchesWithPrefix(wt, "work/", repoName, dryRun, verbose) + // Clean up orphaned multiclaude/*, work/* (legacy), and workspace/* branches + removed, issues := c.cleanupOrphanedBranchesWithPrefix(wt, "multiclaude/", repoName, dryRun, verbose) + totalRemoved += removed + totalIssues += issues + + removed, issues = c.cleanupOrphanedBranchesWithPrefix(wt, "work/", repoName, dryRun, verbose) totalRemoved += removed totalIssues += issues @@ -5289,19 +5441,144 @@ func (c *CLI) repair(args []string) error { fmt.Println("✓ State repaired successfully") if data, ok := resp.Data.(map[string]interface{}); ok { - if removed, ok := data["agents_removed"].(float64); ok && removed > 0 { - fmt.Printf(" Removed %d dead agent(s)\n", int(removed)) + removed := int(data["agents_removed"].(float64)) + fixed := int(data["issues_fixed"].(float64)) + created, _ := data["agents_created"].(float64) + wsCreated, _ := data["workspaces_created"].(float64) + + if removed > 0 { + fmt.Printf(" Removed: %d dead agent(s)\n", removed) + } + if fixed > 0 { + fmt.Printf(" Cleaned: %d orphaned resource(s)\n", fixed) } - if fixed, ok := data["issues_fixed"].(float64); ok && fixed > 0 { - fmt.Printf(" Fixed %d issue(s)\n", int(fixed)) + if created > 0 { + fmt.Printf(" Created: %d core agent(s)\n", int(created)) + } + if wsCreated > 0 { + fmt.Printf(" Created: %d default workspace(s)\n", int(wsCreated)) + } + if removed == 0 && fixed == 0 && created == 0 && wsCreated == 0 { + fmt.Println(" No issues found, no changes needed") } } return nil } -// refresh triggers an immediate worktree sync for all agents +// refresh syncs worktrees with main branch. +// When run inside an agent worktree, refreshes just that worktree directly. +// When run outside an agent context (or with --all), triggers global refresh via daemon. func (c *CLI) refresh(args []string) error { + flags, _ := ParseFlags(args) + refreshAll := flags["all"] == "true" + + // If --all not specified, try to detect agent context + if !refreshAll { + cwd, err := os.Getwd() + if err == nil { + // Resolve symlinks for proper path comparison + if resolved, err := filepath.EvalSymlinks(cwd); err == nil { + cwd = resolved + } + + // Check if we're in a worktree path: ~/.multiclaude/wts// + if hasPathPrefix(cwd, c.paths.WorktreesDir) { + rel, err := filepath.Rel(c.paths.WorktreesDir, cwd) + if err == nil { + parts := strings.SplitN(rel, string(filepath.Separator), 2) + if len(parts) >= 2 && parts[0] != "" && parts[1] != "" { + repoName := parts[0] + agentName := strings.SplitN(parts[1], string(filepath.Separator), 2)[0] + return c.refreshAgentWorktree(repoName, agentName, cwd) + } + } + } + } + } + + // Global refresh via daemon + return c.refreshAllWorktrees() +} + +// refreshAgentWorktree refreshes a single agent's worktree directly +func (c *CLI) refreshAgentWorktree(repoName, agentName, wtPath string) error { + fmt.Printf("Refreshing worktree for %s/%s...\n", repoName, agentName) + + // Get the repo path to determine remote/branch + repoPath := c.paths.RepoDir(repoName) + wt := worktree.NewManager(repoPath) + + // Get remote and main branch + remote, err := wt.GetUpstreamRemote() + if err != nil { + return fmt.Errorf("failed to get remote: %w", err) + } + + mainBranch, err := wt.GetDefaultBranch(remote) + if err != nil { + return fmt.Errorf("failed to get default branch: %w", err) + } + + // Fetch latest from remote + fmt.Printf("Fetching from %s...\n", remote) + if err := wt.FetchRemote(remote); err != nil { + return fmt.Errorf("failed to fetch: %w", err) + } + + // Check worktree state + wtState, err := worktree.GetWorktreeState(wtPath, remote, mainBranch) + if err != nil { + return fmt.Errorf("failed to get worktree state: %w", err) + } + + if !wtState.CanRefresh { + fmt.Printf("✓ No refresh needed: %s\n", wtState.RefreshReason) + return nil + } + + if wtState.CommitsBehind == 0 { + fmt.Println("✓ Already up to date") + return nil + } + + fmt.Printf("Rebasing onto %s/%s (%d commits behind)...\n", remote, mainBranch, wtState.CommitsBehind) + + // Perform the refresh + result := worktree.RefreshWorktree(wtPath, remote, mainBranch) + + if result.Error != nil { + if result.HasConflicts { + fmt.Println("\n⚠ Rebase has conflicts in:") + for _, f := range result.ConflictFiles { + fmt.Printf(" - %s\n", f) + } + fmt.Println("\nResolve conflicts and run 'git rebase --continue', or 'git rebase --abort' to cancel.") + return fmt.Errorf("rebase conflicts") + } + return fmt.Errorf("refresh failed: %w", result.Error) + } + + if result.Skipped { + fmt.Printf("✓ Skipped: %s\n", result.SkipReason) + return nil + } + + fmt.Printf("✓ Successfully rebased %d commits\n", result.CommitsRebased) + if result.WasStashed { + if result.StashRestored { + fmt.Println(" (uncommitted changes were stashed and restored)") + } else { + fmt.Println(" ⚠ Warning: uncommitted changes were stashed but could not be restored") + fmt.Println(" Run 'git stash pop' to restore them manually") + } + } + + return nil +} + +// refreshAllWorktrees triggers a global refresh via the daemon +func (c *CLI) refreshAllWorktrees() error { // Connect to daemon client := socket.NewClient(c.paths.DaemonSock) _, err := client.Send(socket.Request{Command: "ping"}) @@ -5309,7 +5586,7 @@ func (c *CLI) refresh(args []string) error { return errors.DaemonNotRunning() } - fmt.Println("Triggering worktree refresh...") + fmt.Println("Triggering worktree refresh for all agents...") resp, err := client.Send(socket.Request{ Command: "trigger_refresh", @@ -5473,6 +5750,35 @@ func (c *CLI) localRepair(verbose bool) error { fmt.Println("Or use: multiclaude stop-all") } + // Ensure core agents exist for each repository + agentsCreated := 0 + workspacesCreated := 0 + for _, repoName := range st.ListRepos() { + if verbose { + fmt.Printf("\nEnsuring core agents for repository: %s\n", repoName) + } + + // Ensure core agents (supervisor, merge-queue/pr-shepherd) + created, err := c.ensureCoreAgents(st, repoName, verbose) + if err != nil { + if verbose { + fmt.Printf(" Warning: failed to ensure core agents: %v\n", err) + } + } else { + agentsCreated += created + } + + // Ensure default workspace exists + wsCreated, err := c.ensureDefaultWorkspace(st, repoName, verbose) + if err != nil { + if verbose { + fmt.Printf(" Warning: failed to ensure default workspace: %v\n", err) + } + } else if wsCreated { + workspacesCreated++ + } + } + // Save updated state if err := st.Save(); err != nil { return fmt.Errorf("failed to save repaired state: %w", err) @@ -5480,18 +5786,294 @@ func (c *CLI) localRepair(verbose bool) error { fmt.Println("\n✓ Local repair completed") if agentsRemoved > 0 { - fmt.Printf(" Removed %d dead agent(s)\n", agentsRemoved) + fmt.Printf(" Removed: %d dead agent(s)\n", agentsRemoved) } if issuesFixed > 0 { - fmt.Printf(" Fixed %d issue(s)\n", issuesFixed) + fmt.Printf(" Cleaned: %d orphaned resource(s)\n", issuesFixed) } - if agentsRemoved == 0 && issuesFixed == 0 { - fmt.Println(" No issues found") + if agentsCreated > 0 { + fmt.Printf(" Created: %d core agent(s)\n", agentsCreated) + } + if workspacesCreated > 0 { + fmt.Printf(" Created: %d default workspace(s)\n", workspacesCreated) + } + if agentsRemoved == 0 && issuesFixed == 0 && agentsCreated == 0 && workspacesCreated == 0 { + fmt.Println(" No issues found, no changes needed") } return nil } +// ensureCoreAgents ensures that all core agents (supervisor, merge-queue/pr-shepherd) exist +// for a repository. Returns counts of agents created. +func (c *CLI) ensureCoreAgents(st *state.State, repoName string, verbose bool) (int, error) { + repo, exists := st.GetRepo(repoName) + if !exists { + return 0, fmt.Errorf("repository %s not found in state", repoName) + } + + created := 0 + repoPath := c.paths.RepoDir(repoName) + tmuxSession := repo.TmuxSession + tmuxClient := tmux.NewClient() + + // Check if session exists + hasSession, err := tmuxClient.HasSession(context.Background(), tmuxSession) + if err != nil || !hasSession { + if verbose { + fmt.Printf(" Tmux session %s not found, skipping core agent creation\n", tmuxSession) + } + return 0, nil + } + + // Ensure supervisor exists + if _, exists := repo.Agents["supervisor"]; !exists { + if verbose { + fmt.Println(" Creating missing supervisor agent...") + } + if err := c.createCoreAgent(st, repo, repoName, repoPath, "supervisor", state.AgentTypeSupervisor, tmuxClient); err != nil { + return created, fmt.Errorf("failed to create supervisor: %w", err) + } + created++ + } + + // Determine if we should have merge-queue or pr-shepherd + isFork := repo.ForkConfig.IsFork || repo.ForkConfig.ForceForkMode + mqConfig := repo.MergeQueueConfig + psConfig := repo.PRShepherdConfig + + // Default configs if not set + if mqConfig.TrackMode == "" { + mqConfig = state.DefaultMergeQueueConfig() + } + if psConfig.TrackMode == "" { + psConfig = state.DefaultPRShepherdConfig() + } + + if isFork { + // Fork mode: ensure pr-shepherd if enabled + if psConfig.Enabled { + if _, exists := repo.Agents["pr-shepherd"]; !exists { + if verbose { + fmt.Println(" Creating missing pr-shepherd agent...") + } + if err := c.createCoreAgent(st, repo, repoName, repoPath, "pr-shepherd", state.AgentTypePRShepherd, tmuxClient); err != nil { + return created, fmt.Errorf("failed to create pr-shepherd: %w", err) + } + created++ + } + } + } else { + // Non-fork mode: ensure merge-queue if enabled + if mqConfig.Enabled { + if _, exists := repo.Agents["merge-queue"]; !exists { + if verbose { + fmt.Println(" Creating missing merge-queue agent...") + } + if err := c.createCoreAgent(st, repo, repoName, repoPath, "merge-queue", state.AgentTypeMergeQueue, tmuxClient); err != nil { + return created, fmt.Errorf("failed to create merge-queue: %w", err) + } + created++ + } + } + } + + return created, nil +} + +// createCoreAgent creates a core agent (supervisor, merge-queue, or pr-shepherd) +func (c *CLI) createCoreAgent(st *state.State, repo *state.Repository, repoName, repoPath, agentName string, agentType state.AgentType, tmuxClient *tmux.Client) error { + tmuxSession := repo.TmuxSession + + // Check if window already exists + hasWindow, _ := tmuxClient.HasWindow(context.Background(), tmuxSession, agentName) + if !hasWindow { + // Create tmux window + cmd := exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", agentName, "-c", repoPath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create tmux window: %w", err) + } + } + + // Generate session ID + sessionID, err := claude.GenerateSessionID() + if err != nil { + return fmt.Errorf("failed to generate session ID: %w", err) + } + + // Write prompt file + var promptFile string + switch agentType { + case state.AgentTypeSupervisor: + promptFile, err = c.writePromptFile(repoPath, state.AgentTypeSupervisor, agentName) + case state.AgentTypeMergeQueue: + mqConfig := repo.MergeQueueConfig + if mqConfig.TrackMode == "" { + mqConfig = state.DefaultMergeQueueConfig() + } + promptFile, err = c.writeMergeQueuePromptFile(repoPath, agentName, mqConfig) + case state.AgentTypePRShepherd: + psConfig := repo.PRShepherdConfig + if psConfig.TrackMode == "" { + psConfig = state.DefaultPRShepherdConfig() + } + promptFile, err = c.writePRShepherdPromptFile(repoPath, agentName, psConfig, repo.ForkConfig) + default: + return fmt.Errorf("unsupported agent type: %s", agentType) + } + if err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + + // Copy hooks configuration + if err := hooks.CopyConfig(repoPath, repoPath); err != nil && agentType == state.AgentTypeSupervisor { + // Only warn for supervisor + fmt.Printf("Warning: failed to copy hooks config: %v\n", err) + } + + // Start Claude (skip in test mode) + var pid int + if os.Getenv("MULTICLAUDE_TEST_MODE") != "1" { + claudeBinary, err := c.getClaudeBinary() + if err != nil { + return fmt.Errorf("failed to resolve claude binary: %w", err) + } + + pid, err = c.startClaudeInTmux(claudeBinary, tmuxSession, agentName, repoPath, sessionID, promptFile, repoName, "") + if err != nil { + return fmt.Errorf("failed to start Claude: %w", err) + } + + // Set up output capture + if err := c.setupOutputCapture(tmuxSession, agentName, repoName, agentName, string(agentType)); err != nil { + fmt.Printf("Warning: failed to setup output capture: %v\n", err) + } + } + + // Register agent with state + agent := state.Agent{ + Type: agentType, + WorktreePath: repoPath, + TmuxWindow: agentName, + SessionID: sessionID, + PID: pid, + } + + if err := st.AddAgent(repoName, agentName, agent); err != nil { + return fmt.Errorf("failed to add agent to state: %w", err) + } + + return nil +} + +// ensureDefaultWorkspace ensures that at least one workspace exists for a repository. +// If no workspaces exist, creates a default workspace named "my-default-2". +// Returns true if a workspace was created. +func (c *CLI) ensureDefaultWorkspace(st *state.State, repoName string, verbose bool) (bool, error) { + repo, exists := st.GetRepo(repoName) + if !exists { + return false, fmt.Errorf("repository %s not found in state", repoName) + } + + // Check if any workspace already exists + hasWorkspace := false + for _, agent := range repo.Agents { + if agent.Type == state.AgentTypeWorkspace { + hasWorkspace = true + break + } + } + + if hasWorkspace { + return false, nil // Workspace already exists + } + + // Create default workspace + workspaceName := "my-default-2" + if verbose { + fmt.Printf(" Creating default workspace '%s'...\n", workspaceName) + } + + repoPath := c.paths.RepoDir(repoName) + tmuxSession := repo.TmuxSession + + // Check if session exists + tmuxClient := tmux.NewClient() + hasSession, err := tmuxClient.HasSession(context.Background(), tmuxSession) + if err != nil || !hasSession { + if verbose { + fmt.Printf(" Tmux session %s not found, skipping workspace creation\n", tmuxSession) + } + return false, nil + } + + // Create worktree + wt := worktree.NewManager(repoPath) + wtPath := c.paths.AgentWorktree(repoName, workspaceName) + branchName := fmt.Sprintf("workspace/%s", workspaceName) + + if err := wt.CreateNewBranch(wtPath, branchName, "HEAD"); err != nil { + return false, fmt.Errorf("failed to create worktree: %w", err) + } + + // Create tmux window + cmd := exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", workspaceName, "-c", wtPath) + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("failed to create tmux window: %w", err) + } + + // Generate session ID + sessionID, err := claude.GenerateSessionID() + if err != nil { + return false, fmt.Errorf("failed to generate session ID: %w", err) + } + + // Write prompt file + promptFile, err := c.writePromptFile(repoPath, state.AgentTypeWorkspace, workspaceName) + if err != nil { + return false, fmt.Errorf("failed to write prompt file: %w", err) + } + + // Copy hooks configuration + if err := hooks.CopyConfig(repoPath, wtPath); err != nil { + fmt.Printf("Warning: failed to copy hooks config: %v\n", err) + } + + // Start Claude (skip in test mode) + var pid int + if os.Getenv("MULTICLAUDE_TEST_MODE") != "1" { + claudeBinary, err := c.getClaudeBinary() + if err != nil { + return false, fmt.Errorf("failed to resolve claude binary: %w", err) + } + + pid, err = c.startClaudeInTmux(claudeBinary, tmuxSession, workspaceName, wtPath, sessionID, promptFile, repoName, "") + if err != nil { + return false, fmt.Errorf("failed to start Claude: %w", err) + } + + // Set up output capture + if err := c.setupOutputCapture(tmuxSession, workspaceName, repoName, workspaceName, "workspace"); err != nil { + fmt.Printf("Warning: failed to setup output capture: %v\n", err) + } + } + + // Register workspace with state + agent := state.Agent{ + Type: state.AgentTypeWorkspace, + WorktreePath: wtPath, + TmuxWindow: workspaceName, + SessionID: sessionID, + PID: pid, + } + + if err := st.AddAgent(repoName, workspaceName, agent); err != nil { + return false, fmt.Errorf("failed to add workspace to state: %w", err) + } + + return true, nil +} + // restartClaude restarts Claude in the current agent context. // It auto-detects whether to use --resume or --session-id based on session history. func (c *CLI) restartClaude(args []string) error { @@ -5574,7 +6156,11 @@ func (c *CLI) showDocs(args []string) error { return nil } -// GenerateDocumentation generates markdown documentation for all CLI commands +// GenerateDocumentation generates markdown documentation for all CLI commands. +// NOTE: This markdown is injected into agent prompts. When adding or changing +// commands/flags that affect extension surfaces, ensure docs/extending/SOCKET_API.md +// and docs/extending/STATE_FILE_INTEGRATION.md stay accurate and rerun +// `go run ./cmd/verify-docs` so downstream tools/LLMs stay current. func (c *CLI) GenerateDocumentation() string { var sb strings.Builder diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 498577d7..9d51eba3 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1034,6 +1034,271 @@ func TestCLIRepairCommand(t *testing.T) { } } +func TestRepairEnsuringCoreAgents(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Load state + st, err := cli.loadState() + if err != nil { + t.Fatalf("Failed to load state: %v", err) + } + + // Initialize a test repository + repoName := "test-repo" + repoPath := cli.paths.RepoDir(repoName) + tmuxSession := "mc-test-repo" + + // Add repository to state + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: tmuxSession, + Agents: make(map[string]state.Agent), + MergeQueueConfig: state.DefaultMergeQueueConfig(), + PRShepherdConfig: state.DefaultPRShepherdConfig(), + ForkConfig: state.ForkConfig{IsFork: false}, + } + if err := st.AddRepo(repoName, repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create tmux session for testing + if err := exec.Command("tmux", "new-session", "-d", "-s", tmuxSession).Run(); err != nil { + t.Skipf("Skipping test: tmux not available or session creation failed: %v", err) + } + defer exec.Command("tmux", "kill-session", "-t", tmuxSession).Run() + + // Create repository directory and initialize git + if err := os.MkdirAll(repoPath, 0755); err != nil { + t.Fatalf("Failed to create repo directory: %v", err) + } + setupTestRepo(t, repoPath) + + // Run repair (should create supervisor and merge-queue) + err = cli.localRepair(false) + if err != nil { + t.Errorf("localRepair failed: %v", err) + } + + // Reload state to get updated data + st, err = cli.loadState() + if err != nil { + t.Fatalf("Failed to reload state: %v", err) + } + + // Verify supervisor was created + updatedRepo, exists := st.GetRepo(repoName) + if !exists { + t.Fatalf("Repository not found after repair") + } + + if _, exists := updatedRepo.Agents["supervisor"]; !exists { + t.Errorf("Supervisor agent was not created by repair") + } + + // Verify merge-queue was created (in non-fork mode) + if _, exists := updatedRepo.Agents["merge-queue"]; !exists { + t.Errorf("Merge-queue agent was not created by repair") + } + + // Verify default workspace was created + hasWorkspace := false + for _, agent := range updatedRepo.Agents { + if agent.Type == state.AgentTypeWorkspace { + hasWorkspace = true + break + } + } + if !hasWorkspace { + t.Errorf("Default workspace was not created by repair") + } +} + +func TestRepairEnsuringPRShepherdInForkMode(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Load state + st, err := cli.loadState() + if err != nil { + t.Fatalf("Failed to load state: %v", err) + } + + // Initialize a test repository in fork mode + repoName := "test-fork-repo" + repoPath := cli.paths.RepoDir(repoName) + tmuxSession := "mc-test-fork-repo" + + // Add repository to state (fork mode) + repo := &state.Repository{ + GithubURL: "https://github.com/test/fork", + TmuxSession: tmuxSession, + Agents: make(map[string]state.Agent), + MergeQueueConfig: state.MergeQueueConfig{Enabled: false}, + PRShepherdConfig: state.DefaultPRShepherdConfig(), + ForkConfig: state.ForkConfig{ + IsFork: true, + UpstreamURL: "https://github.com/upstream/repo", + UpstreamOwner: "upstream", + UpstreamRepo: "repo", + }, + } + if err := st.AddRepo(repoName, repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create tmux session for testing + if err := exec.Command("tmux", "new-session", "-d", "-s", tmuxSession).Run(); err != nil { + t.Skipf("Skipping test: tmux not available or session creation failed: %v", err) + } + defer exec.Command("tmux", "kill-session", "-t", tmuxSession).Run() + + // Create repository directory and initialize git + if err := os.MkdirAll(repoPath, 0755); err != nil { + t.Fatalf("Failed to create repo directory: %v", err) + } + setupTestRepo(t, repoPath) + + // Run repair (should create supervisor and pr-shepherd, NOT merge-queue) + err = cli.localRepair(false) + if err != nil { + t.Errorf("localRepair failed: %v", err) + } + + // Reload state to get updated data + st, err = cli.loadState() + if err != nil { + t.Fatalf("Failed to reload state: %v", err) + } + + // Verify supervisor was created + updatedRepo, exists := st.GetRepo(repoName) + if !exists { + t.Fatalf("Repository not found after repair") + } + + if _, exists := updatedRepo.Agents["supervisor"]; !exists { + t.Errorf("Supervisor agent was not created by repair") + } + + // Verify pr-shepherd was created (in fork mode) + if _, exists := updatedRepo.Agents["pr-shepherd"]; !exists { + t.Errorf("PR-shepherd agent was not created by repair in fork mode") + } + + // Verify merge-queue was NOT created (fork mode) + if _, exists := updatedRepo.Agents["merge-queue"]; exists { + t.Errorf("Merge-queue agent should not be created in fork mode") + } +} + +func TestRepairDoesNotDuplicateAgents(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Load state + st, err := cli.loadState() + if err != nil { + t.Fatalf("Failed to load state: %v", err) + } + + // Initialize a test repository + repoName := "test-repo" + repoPath := cli.paths.RepoDir(repoName) + tmuxSession := "mc-test-repo" + + // Add repository with existing supervisor + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: tmuxSession, + Agents: map[string]state.Agent{ + "supervisor": { + Type: state.AgentTypeSupervisor, + WorktreePath: repoPath, + TmuxWindow: "supervisor", + SessionID: "existing-session-id", + PID: 12345, + }, + "my-workspace": { + Type: state.AgentTypeWorkspace, + WorktreePath: cli.paths.AgentWorktree(repoName, "my-workspace"), + TmuxWindow: "my-workspace", + SessionID: "workspace-session-id", + PID: 12346, + }, + }, + MergeQueueConfig: state.DefaultMergeQueueConfig(), + PRShepherdConfig: state.DefaultPRShepherdConfig(), + ForkConfig: state.ForkConfig{IsFork: false}, + } + if err := st.AddRepo(repoName, repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create tmux session + if err := exec.Command("tmux", "new-session", "-d", "-s", tmuxSession, "-n", "supervisor").Run(); err != nil { + t.Skipf("Skipping test: tmux not available: %v", err) + } + defer exec.Command("tmux", "kill-session", "-t", tmuxSession).Run() + + // Create workspace window + if err := exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", "my-workspace").Run(); err != nil { + t.Fatalf("Failed to create workspace window: %v", err) + } + + // Create repository directory and initialize git + if err := os.MkdirAll(repoPath, 0755); err != nil { + t.Fatalf("Failed to create repo directory: %v", err) + } + setupTestRepo(t, repoPath) + + // Create workspace worktree directory + wsPath := cli.paths.AgentWorktree(repoName, "my-workspace") + if err := os.MkdirAll(wsPath, 0755); err != nil { + t.Fatalf("Failed to create workspace directory: %v", err) + } + + // Run repair + err = cli.localRepair(false) + if err != nil { + t.Errorf("localRepair failed: %v", err) + } + + // Reload state to get updated data + st, err = cli.loadState() + if err != nil { + t.Fatalf("Failed to reload state: %v", err) + } + + // Verify supervisor still exists with same session ID (not duplicated) + updatedRepo, _ := st.GetRepo(repoName) + supervisor, exists := updatedRepo.Agents["supervisor"] + if !exists { + t.Errorf("Supervisor agent was removed") + } else if supervisor.SessionID != "existing-session-id" { + t.Errorf("Supervisor was replaced instead of kept (session ID changed)") + } + + // Verify merge-queue was created (since it was missing) + if _, exists := updatedRepo.Agents["merge-queue"]; !exists { + t.Errorf("Merge-queue agent was not created") + } + + // Verify default workspace was NOT created (since one already exists) + workspaceCount := 0 + for _, agent := range updatedRepo.Agents { + if agent.Type == state.AgentTypeWorkspace { + workspaceCount++ + } + } + if workspaceCount != 1 { + t.Errorf("Expected 1 workspace, got %d", workspaceCount) + } + if _, exists := updatedRepo.Agents["my-default-2"]; exists { + t.Errorf("Default workspace should not be created when workspace already exists") + } +} + func TestCLIDocsCommand(t *testing.T) { cli, _, cleanup := setupTestEnvironment(t) defer cleanup() @@ -3637,3 +3902,377 @@ func TestLoadStateFunction(t *testing.T) { } }) } + +// ============================================================================= +// Tests for PR #338: Token-aware status display and help improvements +// ============================================================================= + +func TestHibernateCommandIfExists(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + repoCmd, ok := cli.rootCmd.Subcommands["repo"] + if !ok { + t.Fatal("Expected 'repo' command to exist") + } + + hibernateCmd, ok := repoCmd.Subcommands["hibernate"] + if !ok { + t.Skip("hibernate subcommand not yet present") + } + + if hibernateCmd.Description == "" { + t.Error("hibernate command should have a description") + } + if hibernateCmd.Usage == "" { + t.Error("hibernate command should have usage text") + } +} + +func TestHibernateHelpRendering(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + repoCmd, ok := cli.rootCmd.Subcommands["repo"] + if !ok { + t.Fatal("Expected 'repo' command to exist") + } + + if _, ok := repoCmd.Subcommands["hibernate"]; !ok { + t.Skip("hibernate subcommand not yet present") + } + + err := cli.Execute([]string{"repo", "hibernate", "--help"}) + if err != nil { + t.Errorf("repo hibernate --help should not error: %v", err) + } +} + +func TestShowCommandHelpBasic(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + cmd := &Command{ + Name: "test-help", + Description: "Test help rendering", + Usage: "test-help [options]", + } + + err := cli.showCommandHelp(cmd) + if err != nil { + t.Errorf("showCommandHelp() returned error: %v", err) + } +} + +func TestHandleListReposRichResponseShape(t *testing.T) { + _, d, cleanup := setupTestEnvironment(t) + defer cleanup() + + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test-repo", + Agents: make(map[string]state.Agent), + } + if err := d.GetState().AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + d.GetState().AddAgent("test-repo", "supervisor", state.Agent{ + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor", + }) + d.GetState().AddAgent("test-repo", "merge-queue", state.Agent{ + Type: state.AgentTypeMergeQueue, + TmuxWindow: "merge-queue", + }) + d.GetState().AddAgent("test-repo", "happy-fox", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "happy-fox", + Task: "implement feature X", + }) + d.GetState().AddAgent("test-repo", "clever-bear", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "clever-bear", + Task: "fix bug Y", + }) + + client := socket.NewClient(d.GetPaths().DaemonSock) + resp, err := client.Send(socket.Request{ + Command: "list_repos", + Args: map[string]interface{}{"rich": true}, + }) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + if !resp.Success { + t.Fatalf("list_repos failed: %s", resp.Error) + } + + repos, ok := resp.Data.([]interface{}) + if !ok { + t.Fatalf("Expected repos array, got %T", resp.Data) + } + if len(repos) != 1 { + t.Fatalf("Expected 1 repo, got %d", len(repos)) + } + + repoMap, ok := repos[0].(map[string]interface{}) + if !ok { + t.Fatalf("Expected repo map, got %T", repos[0]) + } + + totalAgents, ok := repoMap["total_agents"].(float64) + if !ok { + t.Fatal("Expected total_agents field") + } + if int(totalAgents) != 4 { + t.Errorf("total_agents = %v, want 4", totalAgents) + } + + workerCount, ok := repoMap["worker_count"].(float64) + if !ok { + t.Fatal("Expected worker_count field") + } + if int(workerCount) != 2 { + t.Errorf("worker_count = %v, want 2", workerCount) + } +} + +func TestHandleListReposRichEmptyAgents(t *testing.T) { + _, d, cleanup := setupTestEnvironment(t) + defer cleanup() + + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test-repo", + Agents: make(map[string]state.Agent), + } + if err := d.GetState().AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + client := socket.NewClient(d.GetPaths().DaemonSock) + resp, err := client.Send(socket.Request{ + Command: "list_repos", + Args: map[string]interface{}{"rich": true}, + }) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + if !resp.Success { + t.Fatalf("list_repos failed: %s", resp.Error) + } + + repos, ok := resp.Data.([]interface{}) + if !ok { + t.Fatalf("Expected repos array, got %T", resp.Data) + } + if len(repos) != 1 { + t.Fatalf("Expected 1 repo, got %d", len(repos)) + } + + repoMap := repos[0].(map[string]interface{}) + totalAgents := repoMap["total_agents"].(float64) + if int(totalAgents) != 0 { + t.Errorf("total_agents = %v, want 0", totalAgents) + } + workerCount := repoMap["worker_count"].(float64) + if int(workerCount) != 0 { + t.Errorf("worker_count = %v, want 0", workerCount) + } +} + +// ============================================================================= +// Tests for PR #339: Context-aware refresh with auto-detection +// ============================================================================= + +func TestRefreshContextDetectionFromWorktreePath(t *testing.T) { + tests := []struct { + name string + cwdSuffix string + wantRepo string + wantAgent string + wantDetect bool + }{ + { + name: "agent worktree path", + cwdSuffix: "my-repo/happy-fox", + wantRepo: "my-repo", + wantAgent: "happy-fox", + wantDetect: true, + }, + { + name: "agent worktree with deep subdirectory", + cwdSuffix: "my-repo/happy-fox/src/main", + wantRepo: "my-repo", + wantAgent: "happy-fox", + wantDetect: true, + }, + { + name: "repo-only path", + cwdSuffix: "my-repo", + wantRepo: "my-repo", + wantAgent: "", + wantDetect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "refresh-ctx-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + tmpDir, _ = filepath.EvalSymlinks(tmpDir) + wtsDir := filepath.Join(tmpDir, "wts") + + testPath := filepath.Join(wtsDir, tt.cwdSuffix) + if err := os.MkdirAll(testPath, 0755); err != nil { + t.Fatalf("Failed to create test path: %v", err) + } + + if !hasPathPrefix(testPath, wtsDir) { + t.Fatal("testPath should have wtsDir as prefix") + } + + rel, err := filepath.Rel(wtsDir, testPath) + if err != nil { + t.Fatalf("filepath.Rel failed: %v", err) + } + + parts := strings.SplitN(rel, string(filepath.Separator), 2) + detected := len(parts) >= 2 && parts[0] != "" && parts[1] != "" + + if detected != tt.wantDetect { + t.Errorf("detection = %v, want %v (parts=%v)", detected, tt.wantDetect, parts) + } + + if detected { + repoName := parts[0] + agentName := strings.SplitN(parts[1], string(filepath.Separator), 2)[0] + if repoName != tt.wantRepo { + t.Errorf("repoName = %q, want %q", repoName, tt.wantRepo) + } + if agentName != tt.wantAgent { + t.Errorf("agentName = %q, want %q", agentName, tt.wantAgent) + } + } + }) + } +} + +func TestRefreshParseFlagsAllFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantAll bool + }{ + {"no flags", []string{}, false}, + {"all flag present", []string{"--all"}, true}, + {"other flags without all", []string{"--repo", "my-repo"}, false}, + {"all flag with other flags", []string{"--all", "--repo", "my-repo"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flags, _ := ParseFlags(tt.args) + gotAll := flags["all"] == "true" + if gotAll != tt.wantAll { + t.Errorf("--all = %v, want %v (flags=%v)", gotAll, tt.wantAll, flags) + } + }) + } +} + +func TestRefreshUsageUpdated(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + refreshCmd, ok := cli.rootCmd.Subcommands["refresh"] + if !ok { + t.Fatal("Expected 'refresh' command to exist") + } + + if refreshCmd.Usage == "" { + t.Error("refresh command should have a Usage string") + } + if refreshCmd.Description == "" { + t.Error("refresh command should have a description") + } +} + +func TestContextDetectionEdgeCases(t *testing.T) { + tests := []struct { + name string + path string + wtsDir string + wantMatch bool + }{ + {"path outside worktrees dir", "/home/user/projects/my-repo", "/home/user/.multiclaude/wts", false}, + {"path is exactly the wts dir", "/home/user/.multiclaude/wts", "/home/user/.multiclaude/wts", true}, + {"path with similar prefix", "/home/user/.multiclaude/wts-backup/repo/agent", "/home/user/.multiclaude/wts", false}, + {"path with trailing separator", "/home/user/.multiclaude/wts/repo/agent", "/home/user/.multiclaude/wts/", true}, + {"path with dotfiles", "/home/user/.multiclaude/wts/.hidden-repo/agent", "/home/user/.multiclaude/wts", true}, + {"path with spaces", "/home/user/.multiclaude/wts/my repo/agent", "/home/user/.multiclaude/wts", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hasPathPrefix(tt.path, tt.wtsDir) + if got != tt.wantMatch { + t.Errorf("hasPathPrefix(%q, %q) = %v, want %v", tt.path, tt.wtsDir, got, tt.wantMatch) + } + }) + } +} + +func TestRefreshCommandHelp(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + err := cli.Execute([]string{"refresh", "--help"}) + if err != nil { + t.Errorf("refresh --help should not error: %v", err) + } +} + +func TestAgentContextPathParsing(t *testing.T) { + tests := []struct { + name string + rel string + wantRepo string + wantAgent string + wantOK bool + }{ + {"standard two-component", "multiclaude/happy-fox", "multiclaude", "happy-fox", true}, + {"path with subdir", "multiclaude/happy-fox/internal/cli", "multiclaude", "happy-fox", true}, + {"single component", "multiclaude", "", "", false}, + {"empty relative path", ".", "", "", false}, + {"repo with dots", "my.repo.name/agent-1", "my.repo.name", "agent-1", true}, + {"repo with hyphens", "my-cool-repo/lively-otter", "my-cool-repo", "lively-otter", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parts := strings.SplitN(tt.rel, string(filepath.Separator), 2) + ok := len(parts) >= 2 && parts[0] != "" && parts[1] != "" + + if ok != tt.wantOK { + t.Errorf("detection = %v, want %v (parts=%v)", ok, tt.wantOK, parts) + return + } + + if ok { + repoName := parts[0] + agentName := strings.SplitN(parts[1], string(filepath.Separator), 2)[0] + if repoName != tt.wantRepo { + t.Errorf("repo = %q, want %q", repoName, tt.wantRepo) + } + if agentName != tt.wantAgent { + t.Errorf("agent = %q, want %q", agentName, tt.wantAgent) + } + } + }) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c7554129..3c3bbe4d 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -611,7 +611,11 @@ func (d *Daemon) TriggerWorktreeRefresh() { d.refreshWorktrees() } -// handleRequest handles incoming socket requests +// handleRequest handles incoming socket requests. +// NOTE: This switch defines the socket API surface. When adding or changing +// commands, update docs/extending/SOCKET_API.md and rerun +// `go run ./cmd/verify-docs` so downstream tooling and OpenAPI consumers stay +// in sync. func (d *Daemon) handleRequest(req socket.Request) socket.Response { d.logger.Debug("Handling request: %s", req.Command) @@ -728,12 +732,17 @@ func (d *Daemon) handleListRepos(req socket.Request) socket.Response { // Return detailed repo info repoDetails := make([]map[string]interface{}, 0, len(repos)) for repoName, repo := range repos { - // Count agents by type - workerCount := 0 - totalAgents := len(repo.Agents) - for _, agent := range repo.Agents { + // Group agents by type + workerNames := []string{} + coreAgents := []map[string]string{} // name -> type for core agents + for agentName, agent := range repo.Agents { if agent.Type == state.AgentTypeWorker { - workerCount++ + workerNames = append(workerNames, agentName) + } else { + coreAgents = append(coreAgents, map[string]string{ + "name": agentName, + "type": string(agent.Type), + }) } } @@ -753,8 +762,10 @@ func (d *Daemon) handleListRepos(req socket.Request) socket.Response { "name": repoName, "github_url": repo.GithubURL, "tmux_session": repo.TmuxSession, - "total_agents": totalAgents, - "worker_count": workerCount, + "total_agents": len(repo.Agents), + "worker_count": len(workerNames), + "worker_names": workerNames, + "core_agents": coreAgents, "session_healthy": sessionHealthy, "is_fork": repo.ForkConfig.IsFork, "upstream_owner": repo.ForkConfig.UpstreamOwner, @@ -1245,14 +1256,250 @@ func (d *Daemon) handleRepairState(req socket.Request) socket.Response { } } - d.logger.Info("State repair completed: %d agents removed, %d issues fixed", agentsRemoved, issuesFixed) + // Ensure core agents exist for each repository + agentsCreated := 0 + workspacesCreated := 0 + for _, repoName := range d.state.ListRepos() { + // Ensure core agents (supervisor, merge-queue/pr-shepherd) + created, err := d.ensureCoreAgents(repoName) + if err != nil { + d.logger.Warn("Failed to ensure core agents for %s: %v", repoName, err) + } else { + agentsCreated += created + } + + // Ensure default workspace exists + wsCreated, err := d.ensureDefaultWorkspace(repoName) + if err != nil { + d.logger.Warn("Failed to ensure default workspace for %s: %v", repoName, err) + } else if wsCreated { + workspacesCreated++ + } + } + + d.logger.Info("State repair completed: %d agents removed, %d issues fixed, %d agents created, %d workspaces created", + agentsRemoved, issuesFixed, agentsCreated, workspacesCreated) return socket.SuccessResponse(map[string]interface{}{ - "agents_removed": agentsRemoved, - "issues_fixed": issuesFixed, + "agents_removed": agentsRemoved, + "issues_fixed": issuesFixed, + "agents_created": agentsCreated, + "workspaces_created": workspacesCreated, }) } +// ensureCoreAgents ensures that all core agents (supervisor, merge-queue/pr-shepherd) exist +// for a repository. Returns the count of agents created. +func (d *Daemon) ensureCoreAgents(repoName string) (int, error) { + repo, exists := d.state.GetRepo(repoName) + if !exists { + return 0, fmt.Errorf("repository %s not found in state", repoName) + } + + created := 0 + + // Check if session exists + hasSession, err := d.tmux.HasSession(d.ctx, repo.TmuxSession) + if err != nil || !hasSession { + d.logger.Debug("Tmux session %s not found, skipping core agent creation for %s", repo.TmuxSession, repoName) + return 0, nil + } + + // Ensure supervisor exists + if _, exists := repo.Agents["supervisor"]; !exists { + d.logger.Info("Creating missing supervisor agent for %s", repoName) + if err := d.spawnCoreAgent(repoName, "supervisor", state.AgentTypeSupervisor); err != nil { + return created, fmt.Errorf("failed to create supervisor: %w", err) + } + created++ + } + + // Determine if we should have merge-queue or pr-shepherd + isFork := repo.ForkConfig.IsFork || repo.ForkConfig.ForceForkMode + mqConfig := repo.MergeQueueConfig + psConfig := repo.PRShepherdConfig + + // Default configs if not set + if mqConfig.TrackMode == "" { + mqConfig = state.DefaultMergeQueueConfig() + } + if psConfig.TrackMode == "" { + psConfig = state.DefaultPRShepherdConfig() + } + + if isFork { + // Fork mode: ensure pr-shepherd if enabled + if psConfig.Enabled { + if _, exists := repo.Agents["pr-shepherd"]; !exists { + d.logger.Info("Creating missing pr-shepherd agent for %s", repoName) + if err := d.spawnCoreAgent(repoName, "pr-shepherd", state.AgentTypePRShepherd); err != nil { + return created, fmt.Errorf("failed to create pr-shepherd: %w", err) + } + created++ + } + } + } else { + // Non-fork mode: ensure merge-queue if enabled + if mqConfig.Enabled { + if _, exists := repo.Agents["merge-queue"]; !exists { + d.logger.Info("Creating missing merge-queue agent for %s", repoName) + if err := d.spawnCoreAgent(repoName, "merge-queue", state.AgentTypeMergeQueue); err != nil { + return created, fmt.Errorf("failed to create merge-queue: %w", err) + } + created++ + } + } + } + + return created, nil +} + +// spawnCoreAgent spawns a core agent (supervisor, merge-queue, or pr-shepherd) +func (d *Daemon) spawnCoreAgent(repoName, agentName string, agentType state.AgentType) error { + // This delegates to the existing spawnAgent logic used by the restart mechanism + // We'll use the socket handler internally + args := map[string]interface{}{ + "repo": repoName, + "agent": agentName, + "class": string(agentType), + } + + resp := d.handleRestartAgent(socket.Request{ + Command: "restart_agent", + Args: args, + }) + + if !resp.Success { + return fmt.Errorf("failed to spawn agent: %s", resp.Error) + } + + return nil +} + +// ensureDefaultWorkspace ensures that at least one workspace exists for a repository. +// If no workspaces exist, creates a default workspace named "my-default-2". +// Returns true if a workspace was created. +func (d *Daemon) ensureDefaultWorkspace(repoName string) (bool, error) { + repo, exists := d.state.GetRepo(repoName) + if !exists { + return false, fmt.Errorf("repository %s not found in state", repoName) + } + + // Check if any workspace already exists + hasWorkspace := false + for _, agent := range repo.Agents { + if agent.Type == state.AgentTypeWorkspace { + hasWorkspace = true + break + } + } + + if hasWorkspace { + return false, nil // Workspace already exists + } + + // Check if session exists + hasSession, err := d.tmux.HasSession(d.ctx, repo.TmuxSession) + if err != nil || !hasSession { + d.logger.Debug("Tmux session %s not found, skipping workspace creation for %s", repo.TmuxSession, repoName) + return false, nil + } + + // Create default workspace + workspaceName := "my-default-2" + d.logger.Info("Creating default workspace '%s' for %s", workspaceName, repoName) + + // We need to manually create the workspace + repoPath := d.paths.RepoDir(repoName) + wt := worktree.NewManager(repoPath) + wtPath := d.paths.AgentWorktree(repoName, workspaceName) + branchName := fmt.Sprintf("workspace/%s", workspaceName) + + // Create worktree + if err := wt.CreateNewBranch(wtPath, branchName, "HEAD"); err != nil { + return false, fmt.Errorf("failed to create worktree: %w", err) + } + + // Create tmux window using exec.Command + cmd := exec.Command("tmux", "new-window", "-d", "-t", repo.TmuxSession, "-n", workspaceName, "-c", wtPath) + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("failed to create tmux window: %w", err) + } + + // Generate session ID + sessionID, err := claude.GenerateSessionID() + if err != nil { + return false, fmt.Errorf("failed to generate session ID: %w", err) + } + + // Write prompt file + promptContent, err := prompts.GetPrompt(repoPath, state.AgentTypeWorkspace, "") + if err != nil { + return false, fmt.Errorf("failed to get workspace prompt: %w", err) + } + + promptFile := filepath.Join(d.paths.Root, "prompts", workspaceName+".md") + if err := os.MkdirAll(filepath.Dir(promptFile), 0755); err != nil { + return false, fmt.Errorf("failed to create prompts directory: %w", err) + } + if err := os.WriteFile(promptFile, []byte(promptContent), 0644); err != nil { + return false, fmt.Errorf("failed to write prompt file: %w", err) + } + + // Copy hooks configuration + if err := hooks.CopyConfig(repoPath, wtPath); err != nil { + d.logger.Warn("Failed to copy hooks config for workspace: %v", err) + } + + // Start Claude (skip in test mode) + var pid int + if os.Getenv("MULTICLAUDE_TEST_MODE") != "1" { + // Find Claude binary + claudeBinary, err := exec.LookPath("claude") + if err != nil { + return false, fmt.Errorf("failed to find claude binary: %w", err) + } + + // Build Claude command + claudeCmd := fmt.Sprintf("%s --session-id %s --dangerously-skip-permissions", claudeBinary, sessionID) + if promptFile != "" { + claudeCmd += fmt.Sprintf(" --append-system-prompt-file %s", promptFile) + } + + // Send command to tmux window + target := fmt.Sprintf("%s:%s", repo.TmuxSession, workspaceName) + cmd = exec.Command("tmux", "send-keys", "-t", target, claudeCmd, "C-m") + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("failed to start Claude in tmux: %w", err) + } + + // Wait for Claude to start + time.Sleep(500 * time.Millisecond) + + // Get PID + pid, err = d.tmux.GetPanePID(d.ctx, repo.TmuxSession, workspaceName) + if err != nil { + d.logger.Warn("Failed to get Claude PID for workspace: %v", err) + pid = 0 + } + } + + // Register workspace with state + agent := state.Agent{ + Type: state.AgentTypeWorkspace, + WorktreePath: wtPath, + TmuxWindow: workspaceName, + SessionID: sessionID, + PID: pid, + } + + if err := d.state.AddAgent(repoName, workspaceName, agent); err != nil { + return false, fmt.Errorf("failed to add workspace to state: %w", err) + } + + return true, nil +} + // handleGetRepoConfig returns the configuration for a repository func (d *Daemon) handleGetRepoConfig(req socket.Request) socket.Response { name, errResp, ok := getRequiredStringArg(req.Args, "name", "repository name is required") @@ -1458,7 +1705,7 @@ func (d *Daemon) recordTaskHistory(repoName, agentName string, agent state.Agent branch = b } else { // Fallback: construct expected branch name - branch = "work/" + agentName + branch = "multiclaude/" + agentName } } @@ -1605,7 +1852,7 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { worktreePath = repoPath } else { // Ephemeral agents get their own worktree with a new branch - branchName := fmt.Sprintf("work/%s", agentName) + branchName := fmt.Sprintf("multiclaude/%s", agentName) if err := wt.CreateNewBranch(worktreePath, branchName, "HEAD"); err != nil { return socket.ErrorResponse("failed to create worktree: %v", err) } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index a882edb9..8dda94e4 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -3830,3 +3830,274 @@ func TestRecordTaskHistoryWithSummary(t *testing.T) { t.Errorf("History entry summary = %q, want 'Implemented the feature successfully'", history[0].Summary) } } + +// TestEnsureCoreAgentsEmptyState tests ensureCoreAgents when the repo +// doesn't exist in state, verifying it returns an error. +func TestEnsureCoreAgentsEmptyState(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + // Call with a repo that doesn't exist in state + created, err := d.ensureCoreAgents("nonexistent-repo") + if err == nil { + t.Fatal("Expected error for nonexistent repo, got nil") + } + if created != 0 { + t.Errorf("Expected 0 agents created, got %d", created) + } + if !strings.Contains(err.Error(), "nonexistent-repo") { + t.Errorf("Error should mention repo name, got: %s", err.Error()) + } +} + +// TestEnsureCoreAgentsNoTmuxSession tests ensureCoreAgents when the repo +// exists but the tmux session doesn't — should skip gracefully. +func TestEnsureCoreAgentsNoTmuxSession(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + // Add a repo with a tmux session name that doesn't exist + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-nonexistent-session", + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + created, err := d.ensureCoreAgents("test-repo") + if err != nil { + t.Errorf("Expected no error when session missing, got: %v", err) + } + if created != 0 { + t.Errorf("Expected 0 agents created (no tmux session), got %d", created) + } +} + +// TestEnsureCoreAgentsSkipsExistingAgents tests that ensureCoreAgents does +// NOT attempt to recreate agents that already exist in state. +func TestEnsureCoreAgentsSkipsExistingAgents(t *testing.T) { + tmuxClient := tmux.NewClient() + if !tmuxClient.IsTmuxAvailable() { + t.Skip("tmux is required for this test but not available") + } + + d, cleanup := setupTestDaemon(t) + defer cleanup() + + sessionName := "mc-test-ensure-core" + if err := tmuxClient.CreateSession(context.Background(), sessionName, true); err != nil { + t.Skipf("Cannot create tmux session in this environment: %v", err) + } + defer tmuxClient.KillSession(context.Background(), sessionName) + + // Add repo with existing supervisor agent + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: sessionName, + Agents: map[string]state.Agent{ + "supervisor": { + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor", + PID: 99999, + CreatedAt: time.Now(), + }, + "merge-queue": { + Type: state.AgentTypeMergeQueue, + TmuxWindow: "merge-queue", + PID: 99998, + CreatedAt: time.Now(), + }, + }, + MergeQueueConfig: state.DefaultMergeQueueConfig(), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + created, err := d.ensureCoreAgents("test-repo") + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if created != 0 { + t.Errorf("Expected 0 agents created (all exist), got %d", created) + } + + // Verify agents are still there unchanged + agent, exists := d.state.GetAgent("test-repo", "supervisor") + if !exists { + t.Error("Supervisor agent should still exist") + } + if agent.PID != 99999 { + t.Errorf("Supervisor PID should be unchanged (99999), got %d", agent.PID) + } + + agent, exists = d.state.GetAgent("test-repo", "merge-queue") + if !exists { + t.Error("Merge-queue agent should still exist") + } + if agent.PID != 99998 { + t.Errorf("Merge-queue PID should be unchanged (99998), got %d", agent.PID) + } +} + +// TestEnsureCoreAgentsSkipsForkModeWithPRShepherdExisting tests that in +// fork mode, ensureCoreAgents skips creating pr-shepherd when it already exists. +func TestEnsureCoreAgentsSkipsForkModeWithPRShepherdExisting(t *testing.T) { + tmuxClient := tmux.NewClient() + if !tmuxClient.IsTmuxAvailable() { + t.Skip("tmux is required for this test but not available") + } + + d, cleanup := setupTestDaemon(t) + defer cleanup() + + sessionName := "mc-test-fork-skip" + if err := tmuxClient.CreateSession(context.Background(), sessionName, true); err != nil { + t.Skipf("Cannot create tmux session in this environment: %v", err) + } + defer tmuxClient.KillSession(context.Background(), sessionName) + + // Fork mode repo with supervisor + pr-shepherd already present + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: sessionName, + Agents: map[string]state.Agent{ + "supervisor": { + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor", + PID: 99999, + CreatedAt: time.Now(), + }, + "pr-shepherd": { + Type: state.AgentTypePRShepherd, + TmuxWindow: "pr-shepherd", + PID: 99997, + CreatedAt: time.Now(), + }, + }, + ForkConfig: state.ForkConfig{IsFork: true}, + PRShepherdConfig: state.DefaultPRShepherdConfig(), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + created, err := d.ensureCoreAgents("test-repo") + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if created != 0 { + t.Errorf("Expected 0 agents created (pr-shepherd exists), got %d", created) + } +} + +// TestEnsureDefaultWorkspaceEmptyState tests ensureDefaultWorkspace when +// the repo doesn't exist in state. +func TestEnsureDefaultWorkspaceEmptyState(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + created, err := d.ensureDefaultWorkspace("nonexistent-repo") + if err == nil { + t.Fatal("Expected error for nonexistent repo, got nil") + } + if created { + t.Error("Expected false when repo doesn't exist") + } + if !strings.Contains(err.Error(), "nonexistent-repo") { + t.Errorf("Error should mention repo name, got: %s", err.Error()) + } +} + +// TestEnsureDefaultWorkspaceSkipsExisting tests that ensureDefaultWorkspace +// returns (false, nil) when a workspace agent already exists. +func TestEnsureDefaultWorkspaceSkipsExisting(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test-ws", + Agents: map[string]state.Agent{ + "my-workspace": { + Type: state.AgentTypeWorkspace, + TmuxWindow: "my-workspace", + PID: 88888, + CreatedAt: time.Now(), + }, + }, + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + created, err := d.ensureDefaultWorkspace("test-repo") + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if created { + t.Error("Expected false when workspace already exists") + } +} + +// TestEnsureDefaultWorkspaceNoTmuxSession tests that ensureDefaultWorkspace +// skips creation gracefully when no tmux session exists. +func TestEnsureDefaultWorkspaceNoTmuxSession(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-nonexistent-session", + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + created, err := d.ensureDefaultWorkspace("test-repo") + if err != nil { + t.Errorf("Expected no error when session missing, got: %v", err) + } + if created { + t.Error("Expected false when tmux session doesn't exist") + } +} + +// TestSpawnCoreAgentErrorIncludesDetails tests that spawnCoreAgent wraps +// the handleRestartAgent error message (resp.Error) in the returned error. +func TestSpawnCoreAgentErrorIncludesDetails(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + // Add repo but don't add the agent — handleRestartAgent will fail + // because the agent doesn't exist in state. + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test-spawn", + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + err := d.spawnCoreAgent("test-repo", "supervisor", state.AgentTypeSupervisor) + if err == nil { + t.Fatal("Expected error from spawnCoreAgent, got nil") + } + + // The error should wrap the resp.Error from handleRestartAgent. + // handleRestartAgent returns "agent 'supervisor' not found..." when agent not in state. + errMsg := err.Error() + if !strings.Contains(errMsg, "failed to spawn agent") { + t.Errorf("Error should contain 'failed to spawn agent', got: %s", errMsg) + } + if !strings.Contains(errMsg, "supervisor") { + t.Errorf("Error should reference agent name 'supervisor', got: %s", errMsg) + } + if !strings.Contains(errMsg, "not found") { + t.Errorf("Error should contain 'not found' from handleRestartAgent resp.Error, got: %s", errMsg) + } +} diff --git a/internal/daemon/handlers_test.go b/internal/daemon/handlers_test.go index 9e66ebb6..ae8de520 100644 --- a/internal/daemon/handlers_test.go +++ b/internal/daemon/handlers_test.go @@ -2033,3 +2033,145 @@ func TestHandleGetRepoConfigTableDriven(t *testing.T) { }) } } + +// ============================================================================= +// Tests for PR #338: handleListRepos enriched response +// ============================================================================= + +func TestHandleListReposRichAgentBreakdown(t *testing.T) { + tests := []struct { + name string + setupState func(*state.State) + wantRepoCount int + wantTotalAgents int + wantWorkerCount int + }{ + { + name: "empty repo", + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test", + Agents: make(map[string]state.Agent), + }) + }, + wantRepoCount: 1, + wantTotalAgents: 0, + wantWorkerCount: 0, + }, + { + name: "repo with mixed agents", + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test", + Agents: make(map[string]state.Agent), + }) + s.AddAgent("test-repo", "supervisor", state.Agent{ + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor", + CreatedAt: time.Now(), + }) + s.AddAgent("test-repo", "worker-1", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "worker-1", + Task: "fix bug", + CreatedAt: time.Now(), + }) + }, + wantRepoCount: 1, + wantTotalAgents: 2, + wantWorkerCount: 1, + }, + { + name: "repo with only core agents", + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test", + Agents: make(map[string]state.Agent), + }) + s.AddAgent("test-repo", "supervisor", state.Agent{ + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor", + CreatedAt: time.Now(), + }) + }, + wantRepoCount: 1, + wantTotalAgents: 1, + wantWorkerCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, tt.setupState) + defer cleanup() + + resp := d.handleListRepos(socket.Request{ + Command: "list_repos", + Args: map[string]interface{}{"rich": true}, + }) + + if !resp.Success { + t.Fatalf("handleListRepos() failed: %s", resp.Error) + } + + repos, ok := resp.Data.([]map[string]interface{}) + if !ok { + t.Fatalf("Expected []map[string]interface{}, got %T", resp.Data) + } + + if len(repos) != tt.wantRepoCount { + t.Errorf("repo count = %d, want %d", len(repos), tt.wantRepoCount) + } + + if len(repos) > 0 { + repo := repos[0] + + totalAgents, _ := repo["total_agents"].(int) + if totalAgents != tt.wantTotalAgents { + t.Errorf("total_agents = %d, want %d", totalAgents, tt.wantTotalAgents) + } + + workerCount, _ := repo["worker_count"].(int) + if workerCount != tt.wantWorkerCount { + t.Errorf("worker_count = %d, want %d", workerCount, tt.wantWorkerCount) + } + } + }) + } +} + +func TestHandleListReposSimpleFormat(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, func(s *state.State) { + s.AddRepo("repo-a", &state.Repository{ + GithubURL: "https://github.com/test/repo-a", + TmuxSession: "mc-repo-a", + Agents: make(map[string]state.Agent), + }) + s.AddRepo("repo-b", &state.Repository{ + GithubURL: "https://github.com/test/repo-b", + TmuxSession: "mc-repo-b", + Agents: make(map[string]state.Agent), + }) + }) + defer cleanup() + + resp := d.handleListRepos(socket.Request{ + Command: "list_repos", + }) + + if !resp.Success { + t.Fatalf("handleListRepos() failed: %s", resp.Error) + } + + names, ok := resp.Data.([]string) + if !ok { + t.Fatalf("Expected []string for simple format, got %T", resp.Data) + } + + if len(names) != 2 { + t.Errorf("Expected 2 repo names, got %d", len(names)) + } +} diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 36a41d45..b773472e 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -294,8 +294,8 @@ func TestWorktreeCreationFailed_SpecificSuggestions(t *testing.T) { }{ { name: "branch already exists with name", - causeMsg: "failed to create worktree: exit status 128\nOutput: fatal: a branch named 'work/nice-owl' already exists", - wantContains: []string{"work/nice-owl", "multiclaude cleanup", "git branch -D work/nice-owl"}, + causeMsg: "failed to create worktree: exit status 128\nOutput: fatal: a branch named 'multiclaude/nice-owl' already exists", + wantContains: []string{"multiclaude/nice-owl", "multiclaude cleanup", "git branch -D multiclaude/nice-owl"}, }, { name: "generic branch already exists", @@ -363,7 +363,7 @@ func TestExtractQuotedValue(t *testing.T) { input string want string }{ - {"fatal: a branch named 'work/nice-owl' already exists", "work/nice-owl"}, + {"fatal: a branch named 'multiclaude/nice-owl' already exists", "multiclaude/nice-owl"}, {"some error 'value' here", "value"}, {"no quotes here", ""}, {"'only-one-quote", ""}, diff --git a/internal/prompts/commands/refresh.md b/internal/prompts/commands/refresh.md index 86585831..2083cc9f 100644 --- a/internal/prompts/commands/refresh.md +++ b/internal/prompts/commands/refresh.md @@ -2,7 +2,22 @@ Sync your worktree with the latest changes from the main branch. -## Instructions +## Quick Method (Recommended) + +Run this CLI command - it handles everything automatically: + +```bash +multiclaude refresh +``` + +This will: +- Detect your worktree context automatically +- Fetch from the correct remote (upstream if fork, otherwise origin) +- Stash any uncommitted changes +- Rebase your branch onto main +- Restore stashed changes + +## Manual Instructions (Alternative) 1. First, determine the correct remote to use. Check if an upstream remote exists (indicates a fork): ```bash diff --git a/internal/state/state.go b/internal/state/state.go index 960a1a41..edccdc7a 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -156,6 +156,10 @@ type Agent struct { } // Repository represents a tracked repository's state +// NOTE: This schema is an extension surface. When adding or changing fields, +// update docs/extending/STATE_FILE_INTEGRATION.md and rerun +// `go run ./cmd/verify-docs` so downstream readers/LLMs and OpenAPI consumers +// stay in sync. type Repository struct { GithubURL string `json:"github_url"` TmuxSession string `json:"tmux_session"` diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 9b68c10f..8b203fab 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -1109,7 +1109,7 @@ func TestTaskHistory(t *testing.T) { entry1 := TaskHistoryEntry{ Name: "worker-1", Task: "Implement feature A", - Branch: "work/worker-1", + Branch: "multiclaude/worker-1", Status: TaskStatusUnknown, CreatedAt: time.Now().Add(-2 * time.Hour), CompletedAt: time.Now().Add(-1 * time.Hour), @@ -1117,7 +1117,7 @@ func TestTaskHistory(t *testing.T) { entry2 := TaskHistoryEntry{ Name: "worker-2", Task: "Fix bug B", - Branch: "work/worker-2", + Branch: "multiclaude/worker-2", PRURL: "https://github.com/test/repo/pull/123", PRNumber: 123, Status: TaskStatusMerged, @@ -1226,7 +1226,7 @@ func TestUpdateTaskHistoryStatus(t *testing.T) { entry := TaskHistoryEntry{ Name: "worker-1", Task: "Implement feature A", - Branch: "work/worker-1", + Branch: "multiclaude/worker-1", Status: TaskStatusUnknown, CreatedAt: time.Now().Add(-1 * time.Hour), CompletedAt: time.Now(), @@ -1281,7 +1281,7 @@ func TestTaskHistoryPersistence(t *testing.T) { entry := TaskHistoryEntry{ Name: "worker-1", Task: "Implement feature A", - Branch: "work/worker-1", + Branch: "multiclaude/worker-1", PRURL: "https://github.com/test/repo/pull/789", PRNumber: 789, Status: TaskStatusMerged, diff --git a/internal/templates/agent-templates/worker.md b/internal/templates/agent-templates/worker.md index 4295bfd3..0c9e3bcd 100644 --- a/internal/templates/agent-templates/worker.md +++ b/internal/templates/agent-templates/worker.md @@ -32,6 +32,21 @@ multiclaude message send supervisor "Need help: [your question]" Your branch: `work/` Push to it, create PR from it. +## Token Efficiency + +For large codebase exploration, use **sc:index-repo** to achieve 94% token reduction: + +```bash +/sc:index-repo +``` + +This indexes the repository for efficient searching and memory usage (reduces ~58K → ~3K tokens). Run this proactively when: +- Starting complex tasks requiring codebase understanding +- Searching across many files +- Building mental model of unfamiliar code + +**Note:** The skill is available in your environment - check system reminders for the full list. + ## Environment Hygiene Keep your environment clean: diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 7c3a7c36..0fa3e104 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -425,7 +425,7 @@ func (m *Manager) FetchRemote(remote string) error { // FindMergedUpstreamBranches finds local branches that have been merged into the upstream default branch. // It fetches from the upstream remote first to ensure we have the latest state. -// The branchPrefix filters which branches to check (e.g., "multiclaude/" or "work/"). +// The branchPrefix filters which branches to check (e.g., "multiclaude/" or "workspace/"). // Returns a list of branch names that can be safely deleted. func (m *Manager) FindMergedUpstreamBranches(branchPrefix string) ([]string, error) { // Get the upstream remote name diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 8f0b392c..e1ac7687 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -1194,7 +1194,7 @@ func TestFindMergedUpstreamBranches(t *testing.T) { manager := NewManager(repoPath) // Create a branch that is already merged (same as main) - createBranch(t, repoPath, "work/test-feature") + createBranch(t, repoPath, "multiclaude/test-feature") // Add origin remote cmd := exec.Command("git", "remote", "add", "origin", repoPath) @@ -1206,7 +1206,7 @@ func TestFindMergedUpstreamBranches(t *testing.T) { cmd.Run() // Find merged branches - merged, err := manager.FindMergedUpstreamBranches("work/") + merged, err := manager.FindMergedUpstreamBranches("multiclaude/") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1214,7 +1214,7 @@ func TestFindMergedUpstreamBranches(t *testing.T) { // The branch should be found since it's at the same commit as main found := false for _, b := range merged { - if b == "work/test-feature" { + if b == "multiclaude/test-feature" { found = true break } @@ -1231,7 +1231,7 @@ func TestFindMergedUpstreamBranches(t *testing.T) { manager := NewManager(repoPath) // Create a branch and add a commit to it - cmd := exec.Command("git", "checkout", "-b", "work/unmerged-feature") + cmd := exec.Command("git", "checkout", "-b", "multiclaude/unmerged-feature") cmd.Dir = repoPath cmd.Run() @@ -1262,14 +1262,14 @@ func TestFindMergedUpstreamBranches(t *testing.T) { cmd.Run() // Find merged branches - merged, err := manager.FindMergedUpstreamBranches("work/") + merged, err := manager.FindMergedUpstreamBranches("multiclaude/") if err != nil { t.Fatalf("Unexpected error: %v", err) } // The unmerged branch should NOT be found for _, b := range merged { - if b == "work/unmerged-feature" { + if b == "multiclaude/unmerged-feature" { t.Error("Unmerged branch should not be in the merged list") } } @@ -1282,8 +1282,8 @@ func TestFindMergedUpstreamBranches(t *testing.T) { manager := NewManager(repoPath) // Create branches with different prefixes - createBranch(t, repoPath, "work/test") createBranch(t, repoPath, "multiclaude/test") + createBranch(t, repoPath, "workspace/test") createBranch(t, repoPath, "feature/test") // Add origin remote @@ -1295,15 +1295,15 @@ func TestFindMergedUpstreamBranches(t *testing.T) { cmd.Dir = repoPath cmd.Run() - // Find merged branches with work/ prefix - merged, err := manager.FindMergedUpstreamBranches("work/") + // Find merged branches with multiclaude/ prefix + merged, err := manager.FindMergedUpstreamBranches("multiclaude/") if err != nil { t.Fatalf("Unexpected error: %v", err) } - // Should only find work/test + // Should only find multiclaude/test for _, b := range merged { - if !strings.HasPrefix(b, "work/") { + if !strings.HasPrefix(b, "multiclaude/") { t.Errorf("Branch %s should not be included (wrong prefix)", b) } } @@ -1484,13 +1484,13 @@ func TestListBranchesWithPrefix(t *testing.T) { manager := NewManager(repoPath) // Create branches with different prefixes - createBranch(t, repoPath, "work/feature-1") - createBranch(t, repoPath, "work/feature-2") - createBranch(t, repoPath, "multiclaude/agent-1") + createBranch(t, repoPath, "multiclaude/feature-1") + createBranch(t, repoPath, "multiclaude/feature-2") + createBranch(t, repoPath, "workspace/agent-1") createBranch(t, repoPath, "other/branch") - // List work/ branches - branches, err := manager.ListBranchesWithPrefix("work/") + // List multiclaude/ branches + branches, err := manager.ListBranchesWithPrefix("multiclaude/") if err != nil { t.Fatalf("Failed to list branches: %v", err) } @@ -1499,19 +1499,19 @@ func TestListBranchesWithPrefix(t *testing.T) { t.Errorf("Expected 2 branches, got %d: %v", len(branches), branches) } - // Verify both work/ branches are in the list + // Verify both multiclaude/ branches are in the list foundFeature1 := false foundFeature2 := false for _, b := range branches { - if b == "work/feature-1" { + if b == "multiclaude/feature-1" { foundFeature1 = true } - if b == "work/feature-2" { + if b == "multiclaude/feature-2" { foundFeature2 = true } } if !foundFeature1 || !foundFeature2 { - t.Errorf("Expected to find work/feature-1 and work/feature-2, got: %v", branches) + t.Errorf("Expected to find multiclaude/feature-1 and multiclaude/feature-2, got: %v", branches) } }) @@ -1557,19 +1557,19 @@ func TestFindOrphanedBranches(t *testing.T) { manager := NewManager(repoPath) // Create branches - createBranch(t, repoPath, "work/orphan-1") - createBranch(t, repoPath, "work/orphan-2") - createBranch(t, repoPath, "work/active") + createBranch(t, repoPath, "multiclaude/orphan-1") + createBranch(t, repoPath, "multiclaude/orphan-2") + createBranch(t, repoPath, "multiclaude/active") // Create a worktree for one branch wtPath := filepath.Join(repoPath, "wt-active") - if err := manager.Create(wtPath, "work/active"); err != nil { + if err := manager.Create(wtPath, "multiclaude/active"); err != nil { t.Fatalf("Failed to create worktree: %v", err) } defer manager.Remove(wtPath, true) // Find orphaned branches - orphaned, err := manager.FindOrphanedBranches("work/") + orphaned, err := manager.FindOrphanedBranches("multiclaude/") if err != nil { t.Fatalf("Failed to find orphaned branches: %v", err) } @@ -1580,7 +1580,7 @@ func TestFindOrphanedBranches(t *testing.T) { } for _, b := range orphaned { - if b == "work/active" { + if b == "multiclaude/active" { t.Error("Active branch should not be in orphaned list") } } @@ -1593,15 +1593,15 @@ func TestFindOrphanedBranches(t *testing.T) { manager := NewManager(repoPath) // Create a branch and a worktree for it - createBranch(t, repoPath, "work/active") + createBranch(t, repoPath, "multiclaude/active") wtPath := filepath.Join(repoPath, "wt-active") - if err := manager.Create(wtPath, "work/active"); err != nil { + if err := manager.Create(wtPath, "multiclaude/active"); err != nil { t.Fatalf("Failed to create worktree: %v", err) } defer manager.Remove(wtPath, true) - orphaned, err := manager.FindOrphanedBranches("work/") + orphaned, err := manager.FindOrphanedBranches("multiclaude/") if err != nil { t.Fatalf("Failed to find orphaned branches: %v", err) } @@ -1618,21 +1618,21 @@ func TestFindOrphanedBranches(t *testing.T) { manager := NewManager(repoPath) // Create branches with different prefixes - createBranch(t, repoPath, "work/orphan") createBranch(t, repoPath, "multiclaude/orphan") + createBranch(t, repoPath, "workspace/orphan") - // Find orphaned branches with work/ prefix - orphaned, err := manager.FindOrphanedBranches("work/") + // Find orphaned branches with multiclaude/ prefix + orphaned, err := manager.FindOrphanedBranches("multiclaude/") if err != nil { t.Fatalf("Failed to find orphaned branches: %v", err) } - // Should only find work/orphan + // Should only find multiclaude/orphan if len(orphaned) != 1 { t.Errorf("Expected 1 orphaned branch, got %d: %v", len(orphaned), orphaned) } - if len(orphaned) > 0 && orphaned[0] != "work/orphan" { - t.Errorf("Expected work/orphan, got: %s", orphaned[0]) + if len(orphaned) > 0 && orphaned[0] != "multiclaude/orphan" { + t.Errorf("Expected multiclaude/orphan, got: %s", orphaned[0]) } }) } @@ -1765,10 +1765,10 @@ func TestCleanupMergedBranches(t *testing.T) { manager := NewManager(repoPath) // Create a merged branch - createBranch(t, repoPath, "work/merged-test") + createBranch(t, repoPath, "multiclaude/merged-test") // Verify branch exists - exists, _ := manager.BranchExists("work/merged-test") + exists, _ := manager.BranchExists("multiclaude/merged-test") if !exists { t.Fatal("Branch should exist before cleanup") } @@ -1783,7 +1783,7 @@ func TestCleanupMergedBranches(t *testing.T) { cmd.Run() // Clean up merged branches - deleted, err := manager.CleanupMergedBranches("work/", false) + deleted, err := manager.CleanupMergedBranches("multiclaude/", false) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1793,7 +1793,7 @@ func TestCleanupMergedBranches(t *testing.T) { } // Verify branch is deleted - exists, _ = manager.BranchExists("work/merged-test") + exists, _ = manager.BranchExists("multiclaude/merged-test") if exists { t.Error("Branch should be deleted after cleanup") } @@ -1806,12 +1806,12 @@ func TestCleanupMergedBranches(t *testing.T) { manager := NewManager(repoPath) // Create a branch and a worktree for it - createBranch(t, repoPath, "work/active-branch") + createBranch(t, repoPath, "multiclaude/active-branch") wtPath := filepath.Join(repoPath, "worktrees", "active") os.MkdirAll(filepath.Dir(wtPath), 0755) - err := manager.Create(wtPath, "work/active-branch") + err := manager.Create(wtPath, "multiclaude/active-branch") if err != nil { t.Fatalf("Failed to create worktree: %v", err) } @@ -1827,20 +1827,20 @@ func TestCleanupMergedBranches(t *testing.T) { cmd.Run() // Clean up merged branches - deleted, err := manager.CleanupMergedBranches("work/", false) + deleted, err := manager.CleanupMergedBranches("multiclaude/", false) if err != nil { t.Fatalf("Unexpected error: %v", err) } // The active branch should NOT be deleted for _, b := range deleted { - if b == "work/active-branch" { + if b == "multiclaude/active-branch" { t.Error("Active branch should not be deleted") } } // Verify branch still exists - exists, _ := manager.BranchExists("work/active-branch") + exists, _ := manager.BranchExists("multiclaude/active-branch") if !exists { t.Error("Active branch should still exist") } @@ -2845,21 +2845,21 @@ func TestDeleteRemoteBranch(t *testing.T) { } // Create and push a branch - createBranch(t, repoPath, "work/to-delete") - cmd = exec.Command("git", "push", "-u", "origin", "work/to-delete") + createBranch(t, repoPath, "multiclaude/to-delete") + cmd = exec.Command("git", "push", "-u", "origin", "multiclaude/to-delete") cmd.Dir = repoPath if err := cmd.Run(); err != nil { t.Fatalf("Failed to push branch: %v", err) } // Delete the remote branch - err = manager.DeleteRemoteBranch("origin", "work/to-delete") + err = manager.DeleteRemoteBranch("origin", "multiclaude/to-delete") if err != nil { t.Fatalf("DeleteRemoteBranch failed: %v", err) } // Verify branch was deleted from remote - cmd = exec.Command("git", "ls-remote", "--heads", "origin", "work/to-delete") + cmd = exec.Command("git", "ls-remote", "--heads", "origin", "multiclaude/to-delete") cmd.Dir = repoPath output, _ := cmd.Output() if len(output) > 0 { @@ -2948,10 +2948,10 @@ func TestCleanupMergedBranchesWithRemoteDeletion(t *testing.T) { } // Create a merged branch - createBranch(t, repoPath, "work/merged-remote") + createBranch(t, repoPath, "multiclaude/merged-remote") // Push the branch - cmd = exec.Command("git", "push", "-u", "origin", "work/merged-remote") + cmd = exec.Command("git", "push", "-u", "origin", "multiclaude/merged-remote") cmd.Dir = repoPath if err := cmd.Run(); err != nil { t.Fatalf("Failed to push branch: %v", err) @@ -2965,7 +2965,7 @@ func TestCleanupMergedBranchesWithRemoteDeletion(t *testing.T) { } // Clean up merged branches with remote deletion - deleted, err := manager.CleanupMergedBranches("work/", true) + deleted, err := manager.CleanupMergedBranches("multiclaude/", true) if err != nil { t.Fatalf("CleanupMergedBranches failed: %v", err) } @@ -2975,13 +2975,13 @@ func TestCleanupMergedBranchesWithRemoteDeletion(t *testing.T) { } // Verify local branch is deleted - exists, _ := manager.BranchExists("work/merged-remote") + exists, _ := manager.BranchExists("multiclaude/merged-remote") if exists { t.Error("Local branch should be deleted") } // Verify remote branch is deleted - cmd = exec.Command("git", "ls-remote", "--heads", "origin", "work/merged-remote") + cmd = exec.Command("git", "ls-remote", "--heads", "origin", "multiclaude/merged-remote") cmd.Dir = repoPath output, _ := cmd.Output() if len(output) > 0 { diff --git a/test/e2e_test.go b/test/e2e_test.go index 5a37506a..f5c09e18 100644 --- a/test/e2e_test.go +++ b/test/e2e_test.go @@ -170,7 +170,7 @@ func TestPhase2Integration(t *testing.T) { // Create worktree wt := worktree.NewManager(repoPath) - if err := wt.CreateNewBranch(workerPath, "work/test-worker", "HEAD"); err != nil { + if err := wt.CreateNewBranch(workerPath, "multiclaude/test-worker", "HEAD"); err != nil { t.Fatalf("Failed to create worktree: %v", err) }