diff --git a/CLAUDE.md b/CLAUDE.md index 4069ce3..d6e1fe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,11 @@ This project embraces controlled chaos: multiple agents work simultaneously, pot go build ./cmd/multiclaude # Build binary go install ./cmd/multiclaude # Install to $GOPATH/bin +# CI Guard Rails (run before pushing) +make pre-commit # Fast checks: build + unit tests + verify docs +make check-all # Full CI: all checks that GitHub CI runs +make install-hooks # Install git pre-commit hook + # Test (run before pushing) go test ./... # All tests go test ./internal/daemon # Single package @@ -284,4 +289,4 @@ multiclaude cleanup # Actually clean up vim internal/templates/agent-templates/worker.md go build ./cmd/multiclaude # New workers will use updated prompt -``` +``` \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..72fc1bf --- /dev/null +++ b/Makefile @@ -0,0 +1,125 @@ +# Makefile for multiclaude - Local CI Guard Rails +# Run these targets to verify changes before pushing + +.PHONY: help build test unit-tests e2e-tests verify-docs coverage check-all pre-commit clean + +# Default target +help: + @echo "Multiclaude Local CI Guard Rails" + @echo "" + @echo "Targets that mirror CI checks:" + @echo " make build - Build all packages (CI: Build job)" + @echo " make unit-tests - Run unit tests (CI: Unit Tests job)" + @echo " make e2e-tests - Run E2E tests (CI: E2E Tests job)" + @echo " make verify-docs - Check generated docs are up to date (CI: Verify Generated Docs job)" + @echo " make coverage - Run coverage check (CI: Coverage Check job)" + @echo "" + @echo "Comprehensive checks:" + @echo " make check-all - Run all CI checks locally (recommended before push)" + @echo " make pre-commit - Fast checks suitable for git pre-commit hook" + @echo "" + @echo "Setup:" + @echo " make install-hooks - Install git pre-commit hook" + @echo "" + @echo "Other:" + @echo " make test - Alias for unit-tests" + @echo " make clean - Clean build artifacts" + +# Build - matches CI build job +build: + @echo "==> Building all packages..." + @go build -v ./... + @echo "✓ Build successful" + +# Unit tests - matches CI unit-tests job +unit-tests: + @echo "==> Running unit tests..." + @command -v tmux >/dev/null 2>&1 || { echo "Error: tmux is required for tests. Install with: sudo apt-get install tmux"; exit 1; } + @go test -coverprofile=coverage.out -covermode=atomic ./internal/... ./pkg/... + @go tool cover -func=coverage.out | tail -1 + @echo "✓ Unit tests passed" + +# E2E tests - matches CI e2e-tests job +e2e-tests: + @echo "==> Running E2E tests..." + @command -v tmux >/dev/null 2>&1 || { echo "Error: tmux is required for tests. Install with: sudo apt-get install tmux"; exit 1; } + @git config user.email >/dev/null 2>&1 || git config --global user.email "ci@local.dev" + @git config user.name >/dev/null 2>&1 || git config --global user.name "Local CI" + @go test -v ./test/... + @echo "✓ E2E tests passed" + +# Verify generated docs - matches CI verify-generated-docs job +verify-docs: + @echo "==> Verifying generated docs are up to date..." + @go generate ./pkg/config/... + @if ! git diff --quiet docs/DIRECTORY_STRUCTURE.md; then \ + echo "Error: docs/DIRECTORY_STRUCTURE.md is out of date!"; \ + echo "Run 'go generate ./pkg/config/...' or 'make generate' and commit the changes."; \ + echo ""; \ + echo "Diff:"; \ + git diff docs/DIRECTORY_STRUCTURE.md; \ + exit 1; \ + fi + @echo "==> Verifying extension documentation consistency..." + @go run ./cmd/verify-docs + @echo "✓ Generated docs are up to date" + +# Coverage check - matches CI coverage-check job +coverage: + @echo "==> Checking coverage thresholds..." + @command -v tmux >/dev/null 2>&1 || { echo "Error: tmux is required for tests. Install with: sudo apt-get install tmux"; exit 1; } + @go test -coverprofile=coverage.out -covermode=atomic ./internal/... ./pkg/... + @echo "" + @echo "Coverage summary:" + @go tool cover -func=coverage.out | grep "total:" || true + @echo "" + @echo "Per-package coverage:" + @go test -cover ./internal/... ./pkg/... 2>&1 | grep "coverage:" | sort + @echo "✓ Coverage check complete" + +# Helper to regenerate docs +generate: + @echo "==> Regenerating documentation..." + @go generate ./pkg/config/... + @echo "✓ Documentation regenerated" + +# Alias for unit-tests +test: unit-tests + +# Pre-commit: Fast checks suitable for git hook +# Runs build + unit tests + verify docs (skips slower e2e tests) +pre-commit: build unit-tests verify-docs + @echo "" + @echo "✓ All pre-commit checks passed" + +# Check all: Complete CI validation locally +# Runs all checks that CI will run +check-all: build unit-tests e2e-tests verify-docs coverage + @echo "" + @echo "==========================================" + @echo "✓ All CI checks passed locally!" + @echo "Your changes are ready to push." + @echo "==========================================" + +# Install git hooks +install-hooks: + @echo "==> Installing git pre-commit hook..." + @mkdir -p .git/hooks + @if [ -f .git/hooks/pre-commit ]; then \ + echo "Warning: .git/hooks/pre-commit already exists"; \ + echo "Backing up to .git/hooks/pre-commit.backup"; \ + cp .git/hooks/pre-commit .git/hooks/pre-commit.backup; \ + fi + @cp scripts/pre-commit.sh .git/hooks/pre-commit + @chmod +x .git/hooks/pre-commit + @echo "✓ Git pre-commit hook installed" + @echo "" + @echo "The hook will run 'make pre-commit' before each commit." + @echo "To skip the hook temporarily, use: git commit --no-verify" + +# Clean build artifacts +clean: + @echo "==> Cleaning build artifacts..." + @rm -f coverage.out + @go clean -cache + @echo "✓ Clean complete" diff --git a/WORKER_NAMING_EXAMPLES.md b/WORKER_NAMING_EXAMPLES.md new file mode 100644 index 0000000..6384b3b --- /dev/null +++ b/WORKER_NAMING_EXAMPLES.md @@ -0,0 +1,116 @@ +# Worker Naming Examples + +This document demonstrates the new task-based worker naming feature. + +## Before (Random Names) + +```bash +$ multiclaude worker create "Fix session ID bug in authentication" +Creating worker 'calm-owl' in repo 'myproject' + +$ multiclaude worker create "Add user profile editing" +Creating worker 'jolly-hawk' in repo 'myproject' +``` + +Workers had random adjective-animal names that provided no context about their purpose. + +## After (Task-Based Names) + +```bash +$ multiclaude worker create "Fix session ID bug in authentication" +Creating worker 'fix-session-id-bug' in repo 'myproject' + +$ multiclaude worker create "Add user profile editing" +Creating worker 'add-user-profile-editing' in repo 'myproject' +``` + +Workers now have descriptive names derived from their task descriptions. + +## How It Works + +### 1. Keyword Extraction + +The system extracts meaningful keywords from the task description: + +``` +Task: "Fix the session ID bug in authentication" +Keywords: ["fix", "session", "id", "bug"] (stop words removed: "the", "in") +Name: "fix-session-id-bug" +``` + +### 2. Sanitization + +Names are converted to valid format: + +- Lowercase letters only +- Hyphens separate words +- Special characters removed +- Maximum 50 characters + +``` +Task: "Update API (v2) endpoint configuration!!!" +Keywords: ["update", "api", "v2", "endpoint"] +Name: "update-api-v2-endpoint" +``` + +### 3. Uniqueness + +Duplicate names get numeric suffixes: + +```bash +$ multiclaude worker create "Fix bug in login" +Creating worker 'fix-bug-login' in repo 'myproject' + +$ multiclaude worker create "Fix bug in login" # Same task +Creating worker 'fix-bug-login-2' in repo 'myproject' + +$ multiclaude worker create "Fix bug in login" # Again +Creating worker 'fix-bug-login-3' in repo 'myproject' +``` + +### 4. Fallback to Random Names + +If the task description is invalid or too short, the system falls back to random names: + +```bash +$ multiclaude worker create "!!!" +Creating worker 'happy-platypus' in repo 'myproject' # Fallback + +$ multiclaude worker create "the a an is" # Only stop words +Creating worker 'clever-dolphin' in repo 'myproject' # Fallback +``` + +## Manual Override + +The `--name` flag still works for manual naming: + +```bash +$ multiclaude worker create "Fix bug" --name my-custom-name +Creating worker 'my-custom-name' in repo 'myproject' +``` + +## Real-World Examples + +| Task Description | Generated Name | +|-----------------|----------------| +| "Fix memory leak in database connection pool" | `fix-memory-leak-database` | +| "Implement OAuth2 authentication flow" | `implement-oauth2-authentication-flow` | +| "Refactor user service to use new API" | `refactor-user-service-new` | +| "Add unit tests for payment module" | `add-unit-tests-payment` | +| "Update README with installation instructions" | `update-readme-installation-instructions` | +| "Debug timeout in webhook handler" | `debug-timeout-webhook-handler` | + +## Benefits + +1. **Clarity**: Immediately understand what each worker is doing +2. **Tracking**: Easier to monitor worker progress in logs and tmux +3. **Git branches**: Branch names like `work/fix-session-id-bug` are self-documenting +4. **PR identification**: PRs are easier to identify from their branch names +5. **Debugging**: When something goes wrong, you know which worker to investigate + +## Technical Details + +- Implementation: `internal/names/names.go` +- Tests: `internal/names/names_test.go` +- Specification: `WORKER_NAMING_SPEC.md` +- Integration: `internal/cli/cli.go:createWorker()` diff --git a/WORKER_NAMING_SPEC.md b/WORKER_NAMING_SPEC.md new file mode 100644 index 0000000..fe06932 --- /dev/null +++ b/WORKER_NAMING_SPEC.md @@ -0,0 +1,106 @@ +# Worker Naming Specification + +## Overview + +Workers should have descriptive, task-based names instead of random adjective-animal combinations. This makes it easier to identify what each worker is doing at a glance. + +## Requirements + +### 1. Task Summary Extraction + +Extract a 3-4 word summary from the task description using heuristic processing: + +- Remove common stop words (a, an, the, is, are, to, for, in, on, at, etc.) +- Identify and extract meaningful keywords (nouns, verbs, technical terms) +- Prioritize words at the beginning of the task description +- Limit to 3-4 words to keep names concise + +### 2. Sanitization + +Convert the extracted summary to a valid worker name: + +- Convert to lowercase +- Replace spaces with hyphens +- Remove or replace special characters (keep only alphanumeric and hyphens) +- Collapse multiple consecutive hyphens into one +- Trim leading/trailing hyphens +- Maximum length: 50 characters (truncate if needed) + +### 3. Uniqueness Handling + +Ensure worker names are unique within a repository: + +- Check if the generated name already exists +- If it exists, append numeric suffix: `-2`, `-3`, etc. +- Keep incrementing until a unique name is found + +### 4. Fallback Strategy + +If task extraction fails or produces invalid names: + +- Fall back to the existing random name generator (`names.Generate()`) +- This ensures workers can always be created, even with unusual task descriptions + +### 5. Manual Override + +Preserve the existing `--name` flag to allow users to manually specify worker names. + +## Examples + +| Task Description | Generated Name | +|-----------------|----------------| +| "Fix the session ID bug in authentication" | `fix-session-id-bug` | +| "Add user profile editing feature" | `add-user-profile` | +| "Refactor the database connection logic" | `refactor-database-connection` | +| "Update README documentation" | `update-readme-documentation` | +| "Implement OAuth2 login flow" | `implement-oauth2-login` | +| "Fix bug" (too short) | `fix-bug` | +| "!!!" (invalid) | `happy-platypus` (fallback) | + +## Implementation Details + +### Stop Words List + +Common words to filter out: +``` +a, an, the, is, are, am, was, were, be, been, being, have, has, had, +do, does, did, will, would, should, could, may, might, must, can, +to, for, of, in, on, at, by, with, from, as, into, through, +this, that, these, those, it, its, they, their, there, here, +and, or, but, if, because, when, where, how, what, which, who, why +``` + +### Name Validation + +A valid worker name must: +- Be between 3 and 50 characters long +- Contain at least one alphabetic character +- Not start or end with a hyphen +- Contain only lowercase letters, numbers, and hyphens + +### Edge Cases + +- Empty task description → fallback to random name +- Task with only stop words → fallback to random name +- Task producing name less than 3 characters → fallback to random name +- Very long task → extract key terms and truncate +- Special characters in task → sanitize and remove +- Duplicate name → append numeric suffix + +## Testing Requirements + +Comprehensive tests must cover: + +1. **Basic extraction**: Verify correct keyword extraction from various task descriptions +2. **Sanitization**: Test lowercase conversion, special character handling, hyphen collapsing +3. **Uniqueness**: Test numeric suffix appending for duplicate names +4. **Fallback**: Verify fallback to random names for invalid inputs +5. **Edge cases**: Empty strings, very long strings, special characters only +6. **Integration**: Test within the full worker creation flow + +## Migration + +Existing code should continue to work: +- The `names.Generate()` function remains available for backward compatibility +- New function `names.FromTask(task string)` implements the task-based naming +- CLI code updated to use `FromTask()` by default, with `Generate()` as fallback diff --git a/cmd/verify-docs/main.go b/cmd/verify-docs/main.go index d0c8d60..7e1f5c8 100644 --- a/cmd/verify-docs/main.go +++ b/cmd/verify-docs/main.go @@ -2,25 +2,26 @@ // // This tool checks: // - State schema fields match documentation -// - Event types match documentation // - Socket API commands match documentation // - File paths in docs exist and are correct // // Usage: // // go run cmd/verify-docs/main.go -// go run cmd/verify-docs/main.go --fix # Auto-update docs (future) +// go run cmd/verify-docs/main.go --fix // Auto-update docs (future) package main import ( - "bufio" "flag" "fmt" "go/ast" "go/parser" "go/token" "os" + "reflect" "regexp" + "sort" + "strconv" "strings" ) @@ -42,7 +43,6 @@ func main() { verifications := []Verification{ verifyStateSchema(), - verifyEventTypes(), verifySocketCommands(), verifyFilePaths(), } @@ -77,88 +77,135 @@ func main() { } } -// verifyStateSchema checks that state.State fields are documented +// verifyStateSchema checks that state structs/fields match the docs list. func verifyStateSchema() Verification { v := Verification{Name: "State schema documentation"} - // Parse internal/state/state.go - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, "internal/state/state.go", nil, parser.ParseComments) + codeStructs, err := parseStateStructsFromCode() if err != nil { - v.Message = fmt.Sprintf("Failed to parse state.go: %v", err) + v.Message = err.Error() return v } - // Find struct definitions - structs := make(map[string][]string) - ast.Inspect(node, func(n ast.Node) bool { - typeSpec, ok := n.(*ast.TypeSpec) - if !ok { - return true - } + docStructs, err := parseStateStructsFromDocs() + if err != nil { + v.Message = err.Error() + return v + } - structType, ok := typeSpec.Type.(*ast.StructType) - if !ok { - return true + missingStructs := diffKeys(codeStructs, docStructs) + extraStructs := diffKeys(docStructs, codeStructs) + + var missingFields []string + var extraFields []string + + for name, fields := range codeStructs { + if *verbose { + fmt.Printf("Verifying struct: %s\n", name) } + docFields := docStructs[name] + missingFields = append(missingFields, diffListPrefixed(fields, docFields, name)...) + extraFields = append(extraFields, diffListPrefixed(docFields, fields, name)...) + } - fields := []string{} - for _, field := range structType.Fields.List { - for _, name := range field.Names { - // Skip private fields - if !ast.IsExported(name.Name) { - continue - } - fields = append(fields, name.Name) - } + if len(missingStructs) > 0 || len(extraStructs) > 0 || len(missingFields) > 0 || len(extraFields) > 0 { + var parts []string + if len(missingStructs) > 0 { + parts = append(parts, fmt.Sprintf("missing structs: %s", strings.Join(missingStructs, ", "))) } + if len(extraStructs) > 0 { + parts = append(parts, fmt.Sprintf("undocumented structs removed from code: %s", strings.Join(extraStructs, ", "))) + } + if len(missingFields) > 0 { + parts = append(parts, fmt.Sprintf("missing fields: %s", strings.Join(missingFields, ", "))) + } + if len(extraFields) > 0 { + parts = append(parts, fmt.Sprintf("fields documented but not in code: %s", strings.Join(extraFields, ", "))) + } + v.Message = strings.Join(parts, "; ") + return v + } - structs[typeSpec.Name.Name] = fields - return true - }) + v.Passed = true + return v +} - // Check important structs are documented - importantStructs := []string{ - "State", - "Repository", - "Agent", - "TaskHistoryEntry", - "MergeQueueConfig", - "HookConfig", +// verifySocketCommands checks that socket commands in code and docs are aligned. +func verifySocketCommands() Verification { + v := Verification{Name: "Socket commands documentation"} + + codeCommands, err := parseSocketCommandsFromCode() + if err != nil { + v.Message = err.Error() + return v } - docFile := "docs/extending/STATE_FILE_INTEGRATION.md" - docContent, err := os.ReadFile(docFile) + docCommands, err := parseSocketCommandsFromDocs() if err != nil { - v.Message = fmt.Sprintf("Failed to read %s: %v", docFile, err) + v.Message = err.Error() + return v + } + + if *verbose { + fmt.Printf("Found %d commands in code, %d in docs\n", len(codeCommands), len(docCommands)) + } + + missing := diffList(codeCommands, docCommands) + extra := diffList(docCommands, codeCommands) + + if len(missing) > 0 || len(extra) > 0 { + var parts []string + if len(missing) > 0 { + parts = append(parts, fmt.Sprintf("missing commands: %s", strings.Join(missing, ", "))) + } + if len(extra) > 0 { + parts = append(parts, fmt.Sprintf("commands documented but not in code: %s", strings.Join(extra, ", "))) + } + v.Message = strings.Join(parts, "; ") return v } + v.Passed = true + return v +} + +// verifyFilePaths checks that file paths mentioned in docs exist. +func verifyFilePaths() Verification { + v := Verification{Name: "File path references"} + + docFiles := []string{ + "docs/extending/STATE_FILE_INTEGRATION.md", + "docs/extending/SOCKET_API.md", + } + + // Use double-quoted string with explicit escapes for safety + filePattern := regexp.MustCompile("((?:internal|pkg|cmd)/[^`]+\\.go)") + missing := []string{} - for _, structName := range importantStructs { + + for _, docFile := range docFiles { if *verbose { - fmt.Printf(" Checking struct: %s\n", structName) + fmt.Printf("Checking references in %s\n", docFile) } - - // Check if struct name appears in docs - if !strings.Contains(string(docContent), structName) { - missing = append(missing, structName) - continue + content, err := os.ReadFile(docFile) + if err != nil { + continue // Skip missing docs } - // Check if fields are documented (basic check) - fields := structs[structName] - for _, field := range fields { - // Convert field name to JSON format (snake_case) - jsonField := toSnakeCase(field) - if !strings.Contains(string(docContent), fmt.Sprintf(`"%s"`, jsonField)) { - missing = append(missing, fmt.Sprintf("%s.%s", structName, field)) + matches := filePattern.FindAllStringSubmatch(string(content), -1) + for _, match := range matches { + if len(match) > 1 { + filePath := match[1] + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + missing = append(missing, fmt.Sprintf("%s (referenced in %s)", filePath, docFile)) + } } } } if len(missing) > 0 { - v.Message = fmt.Sprintf("Missing or incomplete: %s", strings.Join(missing, ", ")) + v.Message = fmt.Sprintf("Missing files:\n %s", strings.Join(missing, "\n ")) return v } @@ -166,182 +213,252 @@ func verifyStateSchema() Verification { return v } -// verifyEventTypes checks that all event types are documented -func verifyEventTypes() Verification { - v := Verification{Name: "Event types documentation"} +// parseStateStructsFromCode extracts json field names for tracked structs. +func parseStateStructsFromCode() (map[string][]string, error) { + tracked := map[string]struct{}{ + "State": {}, + "Repository": {}, + "Agent": {}, + "TaskHistoryEntry": {}, + "MergeQueueConfig": {}, + "PRShepherdConfig": {}, + "ForkConfig": {}, + } - // Parse internal/events/events.go fset := token.NewFileSet() - node, err := parser.ParseFile(fset, "internal/events/events.go", nil, parser.ParseComments) + node, err := parser.ParseFile(fset, "internal/state/state.go", nil, parser.ParseComments) if err != nil { - v.Message = fmt.Sprintf("Failed to parse events.go: %v", err) - return v + return nil, fmt.Errorf("failed to parse state.go: %w", err) } - // Find EventType constants - eventTypes := []string{} + structs := make(map[string][]string) + ast.Inspect(node, func(n ast.Node) bool { - genDecl, ok := n.(*ast.GenDecl) - if !ok || genDecl.Tok != token.CONST { + typeSpec, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { return true } - for _, spec := range genDecl.Specs { - valueSpec, ok := spec.(*ast.ValueSpec) - if !ok { + if _, wanted := tracked[typeSpec.Name.Name]; !wanted { + return true + } + + var fields []string + for _, field := range structType.Fields.List { + // skip embedded or unexported fields + if len(field.Names) == 0 { continue } + for _, name := range field.Names { + if !ast.IsExported(name.Name) { + continue + } - for _, name := range valueSpec.Names { - if strings.HasPrefix(name.Name, "Event") { - eventTypes = append(eventTypes, name.Name) + jsonName := jsonTag(field) + if jsonName == "" { + jsonName = toSnakeCase(name.Name) } + if jsonName == "-" || jsonName == "" { + continue + } + fields = append(fields, jsonName) } } + structs[typeSpec.Name.Name] = uniqueSorted(fields) return true }) - // Check if documented - docFile := "docs/extending/EVENT_HOOKS.md" - docContent, err := os.ReadFile(docFile) + return structs, nil +} + +// parseStateStructsFromDocs reads state struct definitions from marker comments. +func parseStateStructsFromDocs() (map[string][]string, error) { + docFile := "docs/extending/STATE_FILE_INTEGRATION.md" + content, err := os.ReadFile(docFile) if err != nil { - v.Message = fmt.Sprintf("Failed to read %s: %v", docFile, err) - return v + return nil, fmt.Errorf("failed to read %s: %w", docFile, err) } - missing := []string{} - for _, eventType := range eventTypes { - // Extract the actual event type string (e.g., EventAgentStarted -> agent_started) - // This is a simplified check - we just check if the constant name appears - if !strings.Contains(string(docContent), eventType) { - missing = append(missing, eventType) + pattern := regexp.MustCompile(`(?m)`) + matches := pattern.FindAllStringSubmatch(string(content), -1) + + structs := make(map[string][]string) + for _, m := range matches { + if len(m) < 3 { + continue } + name := strings.TrimSpace(m[1]) + fields := uniqueSorted(strings.Fields(m[2])) + structs[name] = fields } - if len(missing) > 0 { - v.Message = fmt.Sprintf("Undocumented event types: %s", strings.Join(missing, ", ")) - return v + if len(structs) == 0 { + return nil, fmt.Errorf("no state-struct markers found in %s", docFile) } - v.Passed = true - return v + return structs, nil } -// verifySocketCommands checks that all socket commands are documented -func verifySocketCommands() Verification { - v := Verification{Name: "Socket commands documentation"} - - // Find all case statements in handleRequest - commands := []string{} - - file, err := os.Open("internal/daemon/daemon.go") +// parseSocketCommandsFromCode extracts socket commands from handleRequest. +func parseSocketCommandsFromCode() ([]string, error) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, "internal/daemon/daemon.go", nil, 0) if err != nil { - v.Message = fmt.Sprintf("Failed to open daemon.go: %v", err) - return v + return nil, fmt.Errorf("failed to parse daemon.go: %w", err) } - defer file.Close() - - scanner := bufio.NewScanner(file) - inSwitch := false - casePattern := regexp.MustCompile(`case\s+"([^"]+)":`) - for scanner.Scan() { - line := scanner.Text() + var commands []string - if strings.Contains(line, "switch req.Command") { - inSwitch = true - continue + ast.Inspect(node, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok || fn.Name == nil || fn.Name.Name != "handleRequest" { + return true } - if inSwitch { - if strings.Contains(line, "default:") { - break + ast.Inspect(fn.Body, func(n ast.Node) bool { + sw, ok := n.(*ast.SwitchStmt) + if !ok || !isReqCommand(sw.Tag) { + return true } - matches := casePattern.FindStringSubmatch(line) - if len(matches) > 1 { - commands = append(commands, matches[1]) + for _, stmt := range sw.Body.List { + clause, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + for _, expr := range clause.List { + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + cmd, err := strconv.Unquote(lit.Value) + if err == nil && cmd != "" { + commands = append(commands, cmd) + } + } } - } - } + return true + }) + return false + }) + + return uniqueSorted(commands), nil +} - // Check if documented +// parseSocketCommandsFromDocs reads socket command list from marker comments. +func parseSocketCommandsFromDocs() ([]string, error) { docFile := "docs/extending/SOCKET_API.md" - docContent, err := os.ReadFile(docFile) + content, err := os.ReadFile(docFile) if err != nil { - v.Message = fmt.Sprintf("Failed to read %s: %v", docFile, err) - return v + return nil, fmt.Errorf("failed to read %s: %w", docFile, err) } - missing := []string{} - for _, cmd := range commands { - // Check for command in documentation (should appear as "#### command_name") - if !strings.Contains(string(docContent), cmd) { - missing = append(missing, cmd) - } + list := parseListFromComment(string(content), "socket-commands") + if len(list) == 0 { + return nil, fmt.Errorf("no socket-commands marker found in %s", docFile) } + return list, nil +} - if len(missing) > 0 { - v.Message = fmt.Sprintf("Undocumented commands: %s", strings.Join(missing, ", ")) - return v +// parseListFromComment extracts a newline-delimited list from an HTML comment label. +func parseListFromComment(content, label string) []string { + // Use fmt.Sprintf with double-quoted strings and explicit escapes + // (?s) dot matches newline + // + pattern := fmt.Sprintf("(?s)", regexp.QuoteMeta(label)) + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(content) + if len(matches) < 2 { + return nil } - v.Passed = true - return v + var items []string + for _, line := range strings.Split(matches[1], "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + items = append(items, line) + } + return uniqueSorted(items) } -// verifyFilePaths checks that file paths mentioned in docs exist -func verifyFilePaths() Verification { - v := Verification{Name: "File path references"} - - // Check all extension docs - docFiles := []string{ - "docs/EXTENSIBILITY.md", - "docs/extending/STATE_FILE_INTEGRATION.md", - "docs/extending/EVENT_HOOKS.md", - "docs/extending/WEB_UI_DEVELOPMENT.md", - "docs/extending/SOCKET_API.md", +// isReqCommand checks if the switch tag is req.Command. +func isReqCommand(expr ast.Expr) bool { + sel, ok := expr.(*ast.SelectorExpr) + if !ok { + return false } + id, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + return id.Name == "req" && sel.Sel != nil && sel.Sel.Name == "Command" +} - // Patterns to find file references - // Looking for things like: - // - `internal/state/state.go` - // - `cmd/multiclaude-web/main.go` - // - `pkg/config/config.go` - filePattern := regexp.MustCompile("`((?:internal|pkg|cmd)/[^`]+\\.go)`") - - missing := []string{} - - for _, docFile := range docFiles { - content, err := os.ReadFile(docFile) - if err != nil { - continue // Skip missing docs - } - - matches := filePattern.FindAllStringSubmatch(string(content), -1) - for _, match := range matches { - if len(match) > 1 { - filePath := match[1] +// jsonTag returns the json tag value if present. +func jsonTag(field *ast.Field) string { + if field.Tag == nil { + return "" + } + raw := strings.Trim(field.Tag.Value, "`") + tag := reflect.StructTag(raw).Get("json") + if tag == "" { + return "" + } + parts := strings.Split(tag, ",") + if len(parts) == 0 { + return "" + } + return parts[0] +} - // Check if file exists - if _, err := os.Stat(filePath); os.IsNotExist(err) { - missing = append(missing, fmt.Sprintf("%s (referenced in %s)", filePath, docFile)) - } - } +// diffList returns items in a but not in b. +func diffList(a, b []string) []string { + setB := make(map[string]struct{}, len(b)) + for _, item := range b { + setB[item] = struct{}{} + } + var diff []string + for _, item := range a { + if _, ok := setB[item]; !ok { + diff = append(diff, item) } } + return uniqueSorted(diff) +} - if len(missing) > 0 { - v.Message = fmt.Sprintf("Missing files:\n %s", strings.Join(missing, "\n ")) - return v +// diffListPrefixed returns items in a but not in b, prefixed with struct name. +func diffListPrefixed(a, b []string, prefix string) []string { + items := diffList(a, b) + for i, item := range items { + items[i] = fmt.Sprintf("%s.%s", prefix, item) } + return items +} - v.Passed = true - return v +// diffKeys returns keys in a but not in b. +func diffKeys(a, b map[string][]string) []string { + keysB := make(map[string]struct{}, len(b)) + for k := range b { + keysB[k] = struct{}{} + } + var diff []string + for k := range a { + if _, ok := keysB[k]; !ok { + diff = append(diff, k) + } + } + return uniqueSorted(diff) } -// toSnakeCase converts PascalCase to snake_case +// toSnakeCase converts PascalCase to snake_case. func toSnakeCase(s string) string { var result []rune for i, r := range s { @@ -352,3 +469,19 @@ func toSnakeCase(s string) string { } return strings.ToLower(string(result)) } + +// uniqueSorted returns a sorted unique copy of the slice. +func uniqueSorted(items []string) []string { + set := make(map[string]struct{}, len(items)) + for _, item := range items { + set[item] = struct{}{} + } + + out := make([]string, 0, len(set)) + for item := range set { + out = append(out, item) + } + + sort.Strings(out) + return out +} diff --git a/docs/TASK_MANAGEMENT.md b/docs/TASK_MANAGEMENT.md new file mode 100644 index 0000000..782451c --- /dev/null +++ b/docs/TASK_MANAGEMENT.md @@ -0,0 +1,168 @@ +# Task Management in multiclaude + +## Overview + +multiclaude agents can leverage Claude Code's built-in task management tools to track complex, multi-step work. This document explains how these tools work and when to use them. + +## Claude Code's Task Management Tools + +Claude Code provides four task management tools available to all agents: + +### TaskCreate +Creates a new task in the task list. + +**When to use:** +- Complex multi-step tasks requiring 3+ distinct steps +- Non-trivial operations that benefit from progress tracking +- User provides multiple tasks in a list +- You want to demonstrate thoroughness and organization + +**When NOT to use:** +- Single, straightforward tasks +- Trivial operations (1-2 steps) +- Tasks completable in <3 steps +- Purely conversational or informational work + +**Example:** +``` +TaskCreate({ + subject: "Fix authentication bug in login flow", + description: "Investigate and fix the issue where users can't log in with OAuth. Need to check middleware, token validation, and error handling.", + activeForm: "Fixing authentication bug" +}) +``` + +### TaskUpdate +Updates an existing task's status, owner, or details. + +**Status workflow:** `pending` → `in_progress` → `completed` + +**When to use:** +- Mark task as `in_progress` when starting work +- Mark task as `completed` when finished +- Update task details as requirements clarify +- Establish dependencies between tasks + +**IMPORTANT:** Only mark tasks as `completed` when FULLY done. If you encounter errors, blockers, or partial completion, keep status as `in_progress`. + +### TaskList +Lists all tasks with their current status, owner, and blockedBy dependencies. + +**When to use:** +- Check what tasks are available to work on +- See overall progress on a project +- Find tasks that are blocked +- After completing a task, to find next work + +### TaskGet +Retrieves full details of a specific task by ID. + +**When to use:** +- Before starting work on an assigned task +- To understand task dependencies +- To get complete requirements and context + +## Task Management vs Task Tool + +**Task Management (TaskCreate/Update/List/Get):** +- Tracks progress on multi-step work within a single agent session +- Creates todo-style checklists visible to users +- Helps organize complex workflows +- Persists within the conversation context + +**Task Tool (spawning sub-agents):** +- Delegates work to parallel sub-agents +- Enables concurrent execution of independent operations +- multiclaude already does this at the orchestration level with workers! + +## Best Practices for multiclaude Agents + +### For Worker Agents + +**Use task management when:** +- Your assigned task has multiple logical steps (e.g., "Implement authentication: add middleware, update routes, write tests") +- You want to show progress on a complex feature +- The user asks you to track progress explicitly + +**Don't overuse:** +- For simple bug fixes or single-file changes +- When you're just doing research/exploration +- For trivial operations + +**Example workflow:** +```bash +# Starting a complex task +TaskCreate({ + subject: "Add user authentication endpoint", + description: "Create /api/auth endpoint with JWT validation, rate limiting, and tests", + activeForm: "Adding authentication endpoint" +}) + +# Start work +TaskUpdate({ taskId: "1", status: "in_progress" }) + +# ... do the work ... + +# Complete when done +TaskUpdate({ taskId: "1", status: "completed" }) +``` + +### For Supervisor Agent + +**Use task management for:** +- Tracking multiple workers' overall progress +- Coordinating complex multi-worker efforts +- Breaking down large features into assignable chunks + +**Pattern for supervision:** +1. Create high-level tasks for major work items +2. Assign tasks to workers (use task metadata to track which worker owns what) +3. Update task status as workers report completion +4. Use TaskList to monitor overall progress + +### For Merge Queue Agent + +**Use task management for:** +- Tracking PRs through the merge process +- Managing multiple PR reviews/merges concurrently +- Organizing complex merge conflict resolutions + +## Task Management and PR Creation + +**IMPORTANT:** Task management is for tracking work, NOT for delaying PRs. + +- Create tasks to organize your work into logical blocks +- When a block (task) is complete and tests pass, create a PR immediately +- Don't wait for all tasks to be complete before creating PRs +- Each completed task should generally result in a focused PR + +**Good pattern:** +``` +Task 1: "Add validation function" → Complete → Create PR #1 +Task 2: "Wire validation into API" → Complete → Create PR #2 +Task 3: "Add error handling" → Complete → Create PR #3 +``` + +**Bad pattern:** +``` +Task 1: "Complete validation system" + - Subtask: Add function + - Subtask: Wire into API + - Subtask: Add error handling + → Wait for ALL to complete → Create massive PR +``` + +## Checking if Task Management is Available + +multiclaude automatically detects task management capabilities during daemon startup. Agents can assume these tools are available if running Claude Code v2.0+. + +To check manually: +```bash +multiclaude diagnostics --json | jq '.capabilities.task_management' +``` + +## Related Documentation + +- [Claude Agent SDK - Todo Tracking](https://platform.claude.com/docs/en/agent-sdk/todo-tracking) - Official documentation +- [AGENTS.md](AGENTS.md) - multiclaude agent architecture +- [CLAUDE.md](CLAUDE.md) - Development guide for multiclaude itself diff --git a/docs/extending/SOCKET_API.md b/docs/extending/SOCKET_API.md index 9e49423..5d663a7 100644 --- a/docs/extending/SOCKET_API.md +++ b/docs/extending/SOCKET_API.md @@ -1,154 +1,100 @@ -# Socket API Reference - -> **NOTE: COMMAND VERIFICATION NEEDED** -> -> Not all commands documented here have been verified against the current codebase. -> Hook-related commands (`get_hook_config`, `update_hook_config`) are **not implemented** -> as the event hooks system does not exist. Other commands should be verified against -> `internal/daemon/daemon.go` before use. - -**Extension Point:** Programmatic control via Unix socket IPC - -This guide documents the socket API for building custom control tools, automation scripts, and alternative CLIs. The socket API provides programmatic access to multiclaude state and operations. - -## Overview - -The multiclaude daemon exposes a Unix socket (`~/.multiclaude/daemon.sock`) for IPC. External tools can: -- Query daemon status and state -- Add/remove repositories and agents -- Trigger operations (cleanup, message routing) -- Configure hooks and settings - -**vs. State File:** -- **State File**: Read-only monitoring -- **Socket API**: Full programmatic control - -**vs. CLI:** -- **CLI**: Human-friendly interface (wraps socket API) -- **Socket API**: Machine-friendly interface (structured JSON) - -## Socket Location - -```bash -# Default location -~/.multiclaude/daemon.sock - -# Find programmatically -multiclaude config --paths | jq -r .socket_path -``` +# Socket API (Current Implementation) + + + +The socket API is the only write-capable extension surface in multiclaude today. It is implemented in `internal/daemon/daemon.go` (`handleRequest`). This document tracks only the commands that exist in the code. Anything not listed here is **not implemented**. ## Protocol - -### Request Format - -```json -{ - "command": "status", - "args": { - "key": "value" - } -} -``` - -**Fields:** -- `command` (string, required): Command name (see Command Reference) -- `args` (object, optional): Command-specific arguments - -### Response Format - -```json -{ - "success": true, - "data": { /* command-specific data */ }, - "error": "" -} -``` - -**Fields:** -- `success` (boolean): Whether command succeeded -- `data` (any): Command response data (if successful) -- `error` (string): Error message (if failed) - -## Client Libraries +- Transport: Unix domain socket at `~/.multiclaude/daemon.sock` +- Request type: JSON object `{ "command": "", "args": { ... } }` +- Response type: `{ "success": true|false, "data": any, "error": string }` +- Client helper: `internal/socket.Client` + +## Command Reference (source of truth) +Each command below matches a `case` in `handleRequest`. + +| Command | Description | Args | +|---------|-------------|------| +| `ping` | Health check | none | +| `status` | Daemon status summary | none | +| `stop` | Stop the daemon | none | +| `list_repos` | List tracked repos (optionally rich info) | `rich` (bool, optional) | +| `add_repo` | Track a new repo | `path` (string) | +| `remove_repo` | Stop tracking a repo | `name` (string) | +| `add_agent` | Register an agent in state | `repo`, `name`, `type`, `worktree_path`, `tmux_window`, `session_id`, `pid` | +| `remove_agent` | Remove agent from state | `repo`, `name` | +| `list_agents` | List agents for a repo | `repo` | +| `complete_agent` | Mark agent ready for cleanup | `repo`, `name`, `summary`, `failure_reason` | +| `restart_agent` | Restart a persistent agent | `repo`, `name` | +| `trigger_cleanup` | Force cleanup cycle | none | +| `repair_state` | Run state repair routine | none | +| `get_repo_config` | Get merge-queue / pr-shepherd config | `repo` | +| `update_repo_config` | Update repo config | `repo`, `config` (JSON object) | +| `set_current_repo` | Persist current repo selection | `repo` | +| `get_current_repo` | Read current repo selection | none | +| `clear_current_repo` | Clear current repo selection | none | +| `route_messages` | Force message routing cycle | none | +| `task_history` | Return task history for a repo | `repo` | +| `spawn_agent` | Create a new agent worktree | `repo`, `type`, `task`, `name` (optional) | + +## Minimal client examples ### Go - ```go package main import ( "fmt" + "github.com/dlorenc/multiclaude/internal/socket" ) func main() { - client := socket.NewClient("~/.multiclaude/daemon.sock") - - resp, err := client.Send(socket.Request{ - Command: "status", - }) - + client := socket.NewClient("/home/user/.multiclaude/daemon.sock") + resp, err := client.Send(socket.Request{Command: "ping"}) if err != nil { panic(err) } - - if !resp.Success { - panic(resp.Error) - } - - fmt.Printf("Status: %+v\n", resp.Data) + fmt.Printf("success=%v data=%v\n", resp.Success, resp.Data) } ``` ### Python - ```python -import socket import json -import os - -class MulticlaudeClient: - def __init__(self, sock_path="~/.multiclaude/daemon.sock"): - self.sock_path = os.path.expanduser(sock_path) - - def send(self, command, args=None): - # Connect to socket - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(self.sock_path) - - try: - # Send request - request = {"command": command} - if args: - request["args"] = args - - sock.sendall(json.dumps(request).encode() + b'\n') - - # Read response - data = b'' - while True: - chunk = sock.recv(4096) - if not chunk: - break - data += chunk - try: - response = json.loads(data.decode()) - break - except json.JSONDecodeError: - continue - - if not response['success']: - raise Exception(response['error']) - - return response['data'] - - finally: - sock.close() +import socket -# Usage -client = MulticlaudeClient() -status = client.send("status") -print(f"Daemon running: {status['running']}") +sock_path = "/home/user/.multiclaude/daemon.sock" +req = {"command": "status", "args": {}} + +with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.connect(sock_path) + s.sendall(json.dumps(req).encode("utf-8")) + raw = s.recv(8192) + resp = json.loads(raw.decode("utf-8")) + print(resp) ``` ### Bash @@ -677,62 +623,6 @@ class MulticlaudeClient { } ``` -### Hook Configuration - -#### get_hook_config - -**Description:** Get current hook configuration - -**Request:** -```json -{ - "command": "get_hook_config" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "on_event": "", - "on_pr_created": "/usr/local/bin/notify-slack.sh", - "on_ci_failed": "", - "on_agent_idle": "", - "on_agent_started": "", - "on_agent_stopped": "", - "on_task_assigned": "", - "on_worker_stuck": "", - "on_message_sent": "" - } -} -``` - -#### update_hook_config - -**Description:** Update hook configuration - -**Request:** -```json -{ - "command": "update_hook_config", - "args": { - "on_pr_created": "/usr/local/bin/notify-slack.sh", - "on_ci_failed": "/usr/local/bin/alert.sh" - } -} -``` - -**Args:** Any hook configuration fields (see [`EVENT_HOOKS.md`](EVENT_HOOKS.md)) - -**Response:** -```json -{ - "success": true, - "data": "Hook configuration updated" -} -``` - ### Maintenance #### trigger_cleanup @@ -1151,3 +1041,6 @@ When adding new socket commands: 3. Update this document with command reference 4. Add tests in `internal/daemon/daemon_test.go` 5. Update CLI wrapper in `internal/cli/cli.go` if applicable +6. Add/remove commands **only** when the `handleRequest` switch changes. +7. Keep the `socket-commands` marker above in sync; `go run ./cmd/verify-docs` enforces alignment. +8. If you add arguments, update the table here with the real fields used by the handler. \ No newline at end of file diff --git a/docs/extending/STATE_FILE_INTEGRATION.md b/docs/extending/STATE_FILE_INTEGRATION.md index 5028e2d..a6026d4 100644 --- a/docs/extending/STATE_FILE_INTEGRATION.md +++ b/docs/extending/STATE_FILE_INTEGRATION.md @@ -1,41 +1,16 @@ -# State File Integration Guide +# State File Integration (Read-Only) -**Extension Point:** Read-only monitoring via `~/.multiclaude/state.json` + + + + + + + -This guide documents the complete state file schema and patterns for building external tools that read multiclaude state. This is the **simplest and safest** extension point - no daemon interaction required, zero risk of breaking multiclaude operation. - -## Overview - -The state file (`~/.multiclaude/state.json`) is the single source of truth for: -- All tracked repositories -- All active agents (supervisor, merge-queue, workers, reviews) -- Task history and PR status -- Hook configuration -- Merge queue settings - -**Key Characteristics:** -- **Atomic Writes**: Daemon writes to temp file, then atomic rename (never corrupt) -- **Read-Only for Extensions**: Never modify directly - use socket API instead -- **JSON Format**: Standard, easy to parse in any language -- **Always Available**: Persists across daemon restarts - -## File Location - -```bash -# Default location -~/.multiclaude/state.json - -# Find it programmatically -state_path="$HOME/.multiclaude/state.json" - -# Or use multiclaude config -multiclaude_dir=$(multiclaude config --paths | jq -r .state_file) -``` - -## Complete Schema Reference - -### Root Structure +The daemon persists state to `~/.multiclaude/state.json` and writes it atomically. This file is safe for external tools to **read only**. Write access belongs to the daemon. +## Schema (from `internal/state/state.go`) ```json { "repos": { @@ -56,7 +31,10 @@ multiclaude_dir=$(multiclaude config --paths | jq -r .state_file) "": { /* Agent object */ } }, "task_history": [ /* TaskHistoryEntry objects */ ], - "merge_queue_config": { /* MergeQueueConfig object */ } + "merge_queue_config": { /* MergeQueueConfig object */ }, + "pr_shepherd_config": { /* PRShepherdConfig object */ }, + "fork_config": { /* ForkConfig object */ }, + "target_branch": "main" } ``` @@ -64,7 +42,7 @@ multiclaude_dir=$(multiclaude config --paths | jq -r .state_file) ```json { - "type": "worker", // "supervisor" | "worker" | "merge-queue" | "workspace" | "review" + "type": "worker", // "supervisor" | "worker" | "merge-queue" | "workspace" | "review" | "pr-shepherd" "worktree_path": "/path/to/worktree", "tmux_window": "0", // Window index in tmux session "session_id": "claude-session-id", @@ -126,6 +104,27 @@ multiclaude_dir=$(multiclaude config --paths | jq -r .state_file) - `author`: Only PRs where multiclaude user is the author - `assigned`: Only PRs where multiclaude user is assigned +### PRShepherdConfig Object + +```json +{ + "enabled": true, // Whether pr-shepherd agent runs + "track_mode": "author" // "all" | "author" | "assigned" +} +``` + +### ForkConfig Object + +```json +{ + "is_fork": true, + "upstream_url": "https://github.com/upstream/repo", + "upstream_owner": "upstream", + "upstream_repo": "repo", + "force_fork_mode": false +} +``` + ### HookConfig Object ```json @@ -154,611 +153,89 @@ multiclaude_dir=$(multiclaude config --paths | jq -r .state_file) "agents": { "supervisor": { "type": "supervisor", - "worktree_path": "/home/user/.multiclaude/wts/my-app/supervisor", - "tmux_window": "0", - "session_id": "claude-abc123", "pid": 12345, - "created_at": "2024-01-15T10:00:00Z", - "last_nudge": "2024-01-15T10:30:00Z" - }, - "merge-queue": { - "type": "merge-queue", - "worktree_path": "/home/user/.multiclaude/wts/my-app/merge-queue", - "tmux_window": "1", - "session_id": "claude-def456", - "pid": 12346, - "created_at": "2024-01-15T10:00:00Z", - "last_nudge": "2024-01-15T10:30:00Z" - }, - "clever-fox": { - "type": "worker", - "worktree_path": "/home/user/.multiclaude/wts/my-app/clever-fox", - "tmux_window": "2", - "session_id": "claude-ghi789", - "pid": 12347, - "task": "Add user authentication", - "summary": "", - "failure_reason": "", - "created_at": "2024-01-15T10:15:00Z", - "last_nudge": "2024-01-15T10:30:00Z", + "created_at": "2025-01-01T00:00:00Z", + "last_nudge": "2025-01-01T00:00:00Z", "ready_for_cleanup": false } }, "task_history": [ { - "name": "brave-lion", - "task": "Fix login bug", - "branch": "multiclaude/brave-lion", - "pr_url": "https://github.com/user/my-app/pull/41", - "pr_number": 41, + "name": "clever-fox", + "task": "Add auth", + "branch": "work/clever-fox", + "pr_url": "https://github.com/user/my-app/pull/42", + "pr_number": 42, "status": "merged", - "summary": "Fixed race condition in session validation", - "failure_reason": "", - "created_at": "2024-01-14T15:00:00Z", - "completed_at": "2024-01-14T16:30:00Z" + "created_at": "2025-01-01T00:00:00Z", + "completed_at": "2025-01-02T00:00:00Z" } ], "merge_queue_config": { "enabled": true, "track_mode": "all" - } + }, + "pr_shepherd_config": { + "enabled": true, + "track_mode": "author" + }, + "fork_config": { + "is_fork": true, + "upstream_url": "https://github.com/original/my-app", + "upstream_owner": "original", + "upstream_repo": "my-app", + "force_fork_mode": false + }, + "target_branch": "main" } }, - "current_repo": "my-app", - "hooks": { - "on_event": "", - "on_pr_created": "/usr/local/bin/notify-slack.sh", - "on_ci_failed": "/usr/local/bin/alert-pagerduty.sh" - } + "current_repo": "my-app" } ``` -## Reading the State File - -### Basic Read (Any Language) - -```bash -# Bash -state=$(cat ~/.multiclaude/state.json) -repo_count=$(echo "$state" | jq '.repos | length') - -# Python -import json -with open(os.path.expanduser('~/.multiclaude/state.json')) as f: - state = json.load(f) - -# Node.js -const state = JSON.parse(fs.readFileSync( - path.join(os.homedir(), '.multiclaude/state.json'), - 'utf8' -)); - -# Go -data, _ := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".multiclaude/state.json")) -var state State -json.Unmarshal(data, &state) -``` - -### Watching for Changes - -The state file is updated frequently (every agent action, every status change). Use file watching instead of polling. - -#### Go (fsnotify) +## Reading the state file +### Go ```go package main import ( "encoding/json" - "log" + "fmt" "os" - "github.com/fsnotify/fsnotify" "github.com/dlorenc/multiclaude/internal/state" ) func main() { - watcher, _ := fsnotify.NewWatcher() - defer watcher.Close() - - statePath := os.ExpandEnv("$HOME/.multiclaude/state.json") - watcher.Add(statePath) - - for { - select { - case event := <-watcher.Events: - if event.Op&fsnotify.Write == fsnotify.Write { - // Re-read state - data, _ := os.ReadFile(statePath) - var s state.State - json.Unmarshal(data, &s) - - // Do something with updated state - processState(&s) - } - case err := <-watcher.Errors: - log.Println("Error:", err) - } - } -} -``` - -#### Python (watchdog) - -```python -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler -import json -import os - -class StateFileHandler(FileSystemEventHandler): - def on_modified(self, event): - if event.src_path.endswith('state.json'): - with open(event.src_path) as f: - state = json.load(f) - process_state(state) - -observer = Observer() -observer.schedule( - StateFileHandler(), - os.path.expanduser('~/.multiclaude'), - recursive=False -) -observer.start() -``` - -#### Node.js (chokidar) - -```javascript -const chokidar = require('chokidar'); -const fs = require('fs'); -const path = require('path'); - -const statePath = path.join(os.homedir(), '.multiclaude/state.json'); - -chokidar.watch(statePath).on('change', (path) => { - const state = JSON.parse(fs.readFileSync(path, 'utf8')); - processState(state); -}); -``` - -## Common Queries - -### Get All Active Workers - -```javascript -// JavaScript -const workers = Object.entries(state.repos) - .flatMap(([repoName, repo]) => - Object.entries(repo.agents) - .filter(([_, agent]) => agent.type === 'worker' && agent.pid > 0) - .map(([name, agent]) => ({ - repo: repoName, - name: name, - task: agent.task, - created: new Date(agent.created_at) - })) - ); -``` - -```python -# Python -workers = [ - { - 'repo': repo_name, - 'name': agent_name, - 'task': agent['task'], - 'created': agent['created_at'] - } - for repo_name, repo in state['repos'].items() - for agent_name, agent in repo['agents'].items() - if agent['type'] == 'worker' and agent.get('pid', 0) > 0 -] -``` - -```bash -# Bash/jq -workers=$(cat ~/.multiclaude/state.json | jq -r ' - .repos | to_entries[] | - .value.agents | to_entries[] | - select(.value.type == "worker" and .value.pid > 0) | - {repo: .key, name: .key, task: .value.task} -') -``` - -### Get Recent Task History - -```python -# Python - Get last 10 completed tasks across all repos -from datetime import datetime - -tasks = [] -for repo_name, repo in state['repos'].items(): - for entry in repo.get('task_history', []): - tasks.append({ - 'repo': repo_name, - **entry - }) - -# Sort by completion time, most recent first -tasks.sort(key=lambda x: x.get('completed_at', ''), reverse=True) -recent_tasks = tasks[:10] -``` - -### Calculate Success Rate - -```javascript -// JavaScript -function calculateSuccessRate(state, repoName) { - const history = state.repos[repoName]?.task_history || []; - const total = history.length; - const merged = history.filter(t => t.status === 'merged').length; - return total > 0 ? (merged / total * 100).toFixed(1) : 0; -} -``` - -### Find Stuck Workers - -```python -# Python - Find workers idle for > 30 minutes -from datetime import datetime, timedelta - -now = datetime.utcnow() -stuck_threshold = timedelta(minutes=30) - -stuck_workers = [] -for repo_name, repo in state['repos'].items(): - for agent_name, agent in repo['agents'].items(): - if agent['type'] != 'worker' or agent.get('pid', 0) == 0: - continue - - last_nudge = datetime.fromisoformat( - agent.get('last_nudge', agent['created_at']).replace('Z', '+00:00') - ) - idle_time = now - last_nudge - - if idle_time > stuck_threshold: - stuck_workers.append({ - 'repo': repo_name, - 'name': agent_name, - 'task': agent.get('task'), - 'idle_minutes': idle_time.total_seconds() / 60 - }) -``` - -### Get PR Status Summary - -```bash -# Bash/jq - Count PRs by status -cat ~/.multiclaude/state.json | jq -r ' - .repos[].task_history[] | .status -' | sort | uniq -c - -# Output: -# 5 merged -# 2 open -# 1 closed -``` - -## Building a State Reader Library - -### Go Example - -```go -package multiclaude - -import ( - "encoding/json" - "os" - "path/filepath" - "sync" - - "github.com/dlorenc/multiclaude/internal/state" - "github.com/fsnotify/fsnotify" -) - -type StateReader struct { - path string - mu sync.RWMutex - state *state.State - onChange func(*state.State) -} - -func NewStateReader(path string) (*StateReader, error) { - r := &StateReader{path: path} - if err := r.reload(); err != nil { - return nil, err - } - return r, nil -} - -func (r *StateReader) reload() error { - data, err := os.ReadFile(r.path) + data, err := os.ReadFile("/home/user/.multiclaude/state.json") if err != nil { - return err + panic(err) } - var s state.State - if err := json.Unmarshal(data, &s); err != nil { - return err + var st state.State + if err := json.Unmarshal(data, &st); err != nil { + panic(err) } - r.mu.Lock() - r.state = &s - r.mu.Unlock() - - return nil -} - -func (r *StateReader) Get() *state.State { - r.mu.RLock() - defer r.mu.RUnlock() - return r.state -} - -func (r *StateReader) Watch(onChange func(*state.State)) error { - r.onChange = onChange - - watcher, err := fsnotify.NewWatcher() - if err != nil { - return err + for name := range st.Repos { + fmt.Println("repo", name) } - - if err := watcher.Add(r.path); err != nil { - return err - } - - go func() { - for { - select { - case event := <-watcher.Events: - if event.Op&fsnotify.Write == fsnotify.Write { - r.reload() - if r.onChange != nil { - r.onChange(r.Get()) - } - } - case <-watcher.Errors: - // Handle error - } - } - }() - - return nil -} -``` - -Usage: -```go -reader, _ := multiclaude.NewStateReader( - filepath.Join(os.Getenv("HOME"), ".multiclaude/state.json") -) - -reader.Watch(func(s *state.State) { - fmt.Printf("State updated: %d repos\n", len(s.Repos)) -}) - -// Query current state -state := reader.Get() -for name, repo := range state.Repos { - fmt.Printf("Repo: %s (%d agents)\n", name, len(repo.Agents)) } ``` -## Performance Considerations - -### Read Performance - -- **File Size**: Typically 10-100KB, grows with task history -- **Parse Time**: <1ms for typical state files -- **Watch Overhead**: Minimal with fsnotify/inotify - -### Update Frequency - -The daemon writes to state.json: -- Every agent start/stop -- Every task assignment/completion -- Every status update (every 2 minutes during health checks) -- Every PR created/merged - -**Recommendation:** Use file watching, not polling. Polling < 1s is wasteful. - -### Handling Rapid Updates - -During busy periods (many agents, frequent changes), you may see multiple updates per second. - -**Debouncing Pattern:** - -```javascript -let updateTimeout; -watcher.on('change', () => { - clearTimeout(updateTimeout); - updateTimeout = setTimeout(() => { - const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); - processState(state); // Your logic here - }, 100); // Wait 100ms for update burst to finish -}); -``` - -## Atomic Reads - -The daemon uses atomic writes (write to temp, rename), so you'll never read a corrupt file. However: - -1. **During a write**, you might read the old state -2. **After the rename**, you'll read the new state -3. **Never** will you read a partial write - -This means: **No locking required** - just read whenever you want. - -## Schema Evolution - -### Version Compatibility - -Currently, the state file has no explicit version field. If the schema changes: - -1. **Backward-compatible changes** (new fields): Your code ignores unknown fields -2. **Breaking changes** (removed/renamed fields): Will be announced in release notes - -**Future-proofing your code:** - -```javascript -// Defensive access -const agentTask = agent.task || agent.description || 'Unknown'; -const status = entry.status || 'unknown'; -``` - -### Deprecated Fields - -The schema has evolved over time. Some historical notes: - -- `merge_queue_config` was added later - older state files won't have it -- If missing, assume `DefaultMergeQueueConfig()`: `{enabled: true, track_mode: "all"}` - -## Troubleshooting - -### State File Missing - -```bash -# Check if multiclaude is initialized -if [ ! -f ~/.multiclaude/state.json ]; then - echo "Error: multiclaude not initialized" - echo "Run: multiclaude init " - exit 1 -fi -``` - -### State File Permissions - -```bash -# State file should be user-readable -ls -l ~/.multiclaude/state.json -# -rw-r--r-- 1 user user ... - -# If not readable, check daemon logs -tail ~/.multiclaude/daemon.log -``` - -### Parse Errors - +### Python ```python import json -try: - with open(state_path) as f: - state = json.load(f) -except json.JSONDecodeError as e: - # This should never happen due to atomic writes - # If it does, the state file is corrupted - print(f"Error parsing state: {e}") - print("Check daemon logs and consider restarting daemon") -``` - -### Stale Data - -If state seems stale (agents shown as running but they're not): - -```bash -# Trigger daemon health check -multiclaude cleanup --dry-run +from pathlib import Path -# Or force state refresh -pkill -USR1 multiclaude # Send signal to daemon (future feature) +state_path = Path.home() / ".multiclaude" / "state.json" +state = json.loads(state_path.read_text()) +for repo, data in state.get("repos", {}).items(): + print("repo", repo, "agents", list(data.get("agents", {}).keys())) ``` -## Real-World Examples - -### Example 1: Prometheus Exporter - -Export multiclaude metrics to Prometheus: - -```python -from prometheus_client import start_http_server, Gauge -import json, time, os - -# Define metrics -agents_gauge = Gauge('multiclaude_agents_total', 'Number of agents', ['repo', 'type']) -tasks_counter = Gauge('multiclaude_tasks_total', 'Completed tasks', ['repo', 'status']) - -def update_metrics(): - with open(os.path.expanduser('~/.multiclaude/state.json')) as f: - state = json.load(f) - - # Update agent counts - for repo_name, repo in state['repos'].items(): - agent_types = {} - for agent in repo['agents'].values(): - t = agent['type'] - agent_types[t] = agent_types.get(t, 0) + 1 - - for agent_type, count in agent_types.items(): - agents_gauge.labels(repo=repo_name, type=agent_type).set(count) - - # Update task history counts - for repo_name, repo in state['repos'].items(): - status_counts = {} - for entry in repo.get('task_history', []): - s = entry['status'] - status_counts[s] = status_counts.get(s, 0) + 1 - - for status, count in status_counts.items(): - tasks_counter.labels(repo=repo_name, status=status).set(count) - -if __name__ == '__main__': - start_http_server(9090) - while True: - update_metrics() - time.sleep(15) # Update every 15 seconds -``` - -### Example 2: CLI Status Monitor - -Simple CLI tool to show current status: - -```bash -#!/bin/bash -# multiclaude-status.sh - Show active workers - -state=$(cat ~/.multiclaude/state.json) - -echo "=== Active Workers ===" -echo "$state" | jq -r ' - .repos | to_entries[] | - .value.agents | to_entries[] | - select(.value.type == "worker" and .value.pid > 0) | - "\(.key): \(.value.task)" -' - -echo "" -echo "=== Recent Completions ===" -echo "$state" | jq -r ' - .repos[].task_history[] | - select(.status == "merged") | - "\(.name): \(.summary)" -' | tail -5 -``` - -### Example 3: Web Dashboard API - -> **Note:** The reference implementation (`internal/dashboard/`, `cmd/multiclaude-web`) does not exist. -> See WEB_UI_DEVELOPMENT.md for design patterns if building a dashboard in a fork. - -A web dashboard would typically include: -- REST endpoints for repos, agents, history -- Server-Sent Events for live updates -- State watching with fsnotify - -## Related Documentation - -- **[`EXTENSIBILITY.md`](../EXTENSIBILITY.md)** - Overview of all extension points -- **[`WEB_UI_DEVELOPMENT.md`](WEB_UI_DEVELOPMENT.md)** - Building dashboards with state reader -- **[`SOCKET_API.md`](SOCKET_API.md)** - For writing state (not just reading) -- `internal/state/state.go` - Canonical Go schema definition - -## Contributing - -When proposing schema changes: - -1. Update this document first -2. Update `internal/state/state.go` -3. Verify backward compatibility -4. Add migration notes to release notes -5. Update all code examples in this doc +## Updating this doc +- Keep the `state-struct` markers above in sync with `internal/state/state.go`. +- Do **not** add fields here unless they exist in the structs. +- Run `go run ./cmd/verify-docs` after schema changes; CI will block if docs drift. \ No newline at end of file diff --git a/go.mod b/go.mod index 4c53182..2e2b2f3 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25.1 require ( github.com/fatih/color v1.18.0 github.com/google/uuid v1.6.0 - gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index 4932556..8916130 100644 --- a/go.sum +++ b/go.sum @@ -11,7 +11,3 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agents/agents.go b/internal/agents/agents.go index 28d1e1a..bf90836 100644 --- a/internal/agents/agents.go +++ b/internal/agents/agents.go @@ -34,6 +34,9 @@ const ( // SourceRepo indicates the definition came from /.multiclaude/agents/ SourceRepo DefinitionSource = "repo" + + // SourceMerged indicates the definition is a merge of local (base) and repo (custom) content + SourceMerged DefinitionSource = "merged" ) // Reader reads agent definitions from the filesystem. @@ -92,7 +95,9 @@ func (r *Reader) ReadAllDefinitions() ([]Definition, error) { } // MergeDefinitions merges local and repo definitions. -// Repo definitions take precedence over local definitions on filename conflict. +// When a repo definition has the same name as a local definition, the repo content +// is appended to the local content (preserving critical base instructions). +// New repo-only definitions are added as-is. func MergeDefinitions(local, repo []Definition) []Definition { // Build a map with local definitions first merged := make(map[string]Definition, len(local)+len(repo)) @@ -101,9 +106,20 @@ func MergeDefinitions(local, repo []Definition) []Definition { merged[def.Name] = def } - // Repo definitions overwrite local ones - for _, def := range repo { - merged[def.Name] = def + // For repo definitions: append to local if exists, otherwise add as new + for _, repoDef := range repo { + if localDef, exists := merged[repoDef.Name]; exists { + // Append repo content to local base template + merged[repoDef.Name] = Definition{ + Name: repoDef.Name, + Content: mergeContent(localDef.Content, repoDef.Content), + SourcePath: localDef.SourcePath, // Keep local path as primary + Source: SourceMerged, + } + } else { + // New repo-only definition, add as-is + merged[repoDef.Name] = repoDef + } } // Convert to sorted slice @@ -119,6 +135,15 @@ func MergeDefinitions(local, repo []Definition) []Definition { return result } +// mergeContent appends custom content to base content with a clear separator. +func mergeContent(base, custom string) string { + // Trim trailing whitespace from base and leading whitespace from custom + base = strings.TrimRight(base, "\n\r\t ") + custom = strings.TrimLeft(custom, "\n\r\t ") + + return base + "\n\n---\n\n## Custom Instructions\n\n" + custom +} + // readDefinitionsFromDir reads all .md files from a directory and returns them as definitions. // Returns an empty slice (not an error) if the directory doesn't exist. func readDefinitionsFromDir(dir string, source DefinitionSource) ([]Definition, error) { diff --git a/internal/agents/agents_test.go b/internal/agents/agents_test.go index 7eebf50..d300fab 100644 --- a/internal/agents/agents_test.go +++ b/internal/agents/agents_test.go @@ -3,6 +3,7 @@ package agents import ( "os" "path/filepath" + "strings" "testing" ) @@ -165,16 +166,23 @@ func TestMergeDefinitions(t *testing.T) { defMap[def.Name] = def } - // Check that repo definition wins for worker + // Check that worker is merged (base + custom appended) worker, ok := defMap["worker"] if !ok { t.Fatal("worker not found in merged") } - if worker.Content != "repo worker" { - t.Errorf("expected repo worker content, got %s", worker.Content) + // Should contain both local (base) and repo (custom) content + if !strings.Contains(worker.Content, "local worker") { + t.Errorf("merged worker should contain base content, got: %s", worker.Content) } - if worker.Source != SourceRepo { - t.Errorf("expected source repo, got %s", worker.Source) + if !strings.Contains(worker.Content, "repo worker") { + t.Errorf("merged worker should contain custom content, got: %s", worker.Content) + } + if !strings.Contains(worker.Content, "## Custom Instructions") { + t.Errorf("merged worker should contain separator, got: %s", worker.Content) + } + if worker.Source != SourceMerged { + t.Errorf("expected source merged, got %s", worker.Source) } // Check that local-only definition is preserved @@ -205,6 +213,41 @@ func TestMergeDefinitions(t *testing.T) { } } +func TestMergeDefinitionsContentFormat(t *testing.T) { + local := []Definition{ + {Name: "worker", Content: "Base instructions\n\n## Your Job\n\nDo things.\n", Source: SourceLocal}, + } + + repo := []Definition{ + {Name: "worker", Content: "\n\nAlso do these extra things.\n", Source: SourceRepo}, + } + + merged := MergeDefinitions(local, repo) + + worker := merged[0] + + // Check that content is properly merged with separator + // Base content should come first + if !strings.Contains(worker.Content, "Base instructions") { + t.Error("merged content should start with base content") + } + // Separator should be present + if !strings.Contains(worker.Content, "---\n\n## Custom Instructions") { + t.Error("merged content should contain separator") + } + // Custom content should come after separator + if !strings.Contains(worker.Content, "Also do these extra things") { + t.Error("merged content should contain custom content") + } + // Verify order: base comes before separator, separator comes before custom + baseIdx := strings.Index(worker.Content, "Base instructions") + sepIdx := strings.Index(worker.Content, "---\n\n## Custom Instructions") + customIdx := strings.Index(worker.Content, "Also do these extra things") + if baseIdx >= sepIdx || sepIdx >= customIdx { + t.Errorf("content not in expected order (base < separator < custom): base=%d, sep=%d, custom=%d", baseIdx, sepIdx, customIdx) + } +} + func TestReadAllDefinitions(t *testing.T) { // Create temp directory structure tmpDir, err := os.MkdirTemp("", "agents-test-*") @@ -262,10 +305,19 @@ func TestReadAllDefinitions(t *testing.T) { } } - // Verify worker is from repo + // Verify worker is merged (contains both local and repo content) for _, def := range defs { - if def.Name == "worker" && def.Source != SourceRepo { - t.Errorf("expected worker to be from repo, got %s", def.Source) + if def.Name == "worker" { + if def.Source != SourceMerged { + t.Errorf("expected worker to be merged, got %s", def.Source) + } + // Check that both contents are present + if !strings.Contains(def.Content, "local worker") { + t.Errorf("merged worker should contain local base content") + } + if !strings.Contains(def.Content, "repo worker") { + t.Errorf("merged worker should contain repo custom content") + } } } } diff --git a/internal/bugreport/collector_test.go b/internal/bugreport/collector_test.go index 7fe0e39..284f93c 100644 --- a/internal/bugreport/collector_test.go +++ b/internal/bugreport/collector_test.go @@ -31,6 +31,7 @@ func TestCollector_Collect(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } // Create a test state file @@ -123,6 +124,7 @@ func TestCollector_CollectVerbose(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } // Create a test state file with multiple repos diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d1c8b02..e9748ad 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -16,6 +16,7 @@ import ( "github.com/dlorenc/multiclaude/internal/agents" "github.com/dlorenc/multiclaude/internal/bugreport" "github.com/dlorenc/multiclaude/internal/daemon" + "github.com/dlorenc/multiclaude/internal/diagnostics" "github.com/dlorenc/multiclaude/internal/errors" "github.com/dlorenc/multiclaude/internal/fork" "github.com/dlorenc/multiclaude/internal/format" @@ -320,6 +321,14 @@ func (c *CLI) registerCommands() { Run: c.startDaemon, } + // Root-level status command - comprehensive system overview + c.rootCmd.Subcommands["status"] = &Command{ + Name: "status", + Description: "Show system status overview", + Usage: "multiclaude status", + Run: c.systemStatus, + } + daemonCmd := &Command{ Name: "daemon", Description: "Manage the multiclaude daemon", @@ -426,6 +435,13 @@ func (c *CLI) registerCommands() { Run: c.showHistory, } + repoCmd.Subcommands["hibernate"] = &Command{ + Name: "hibernate", + Description: "Hibernate a repository, archiving uncommitted changes", + Usage: "multiclaude repo hibernate [--repo ] [--all] [--yes]", + Run: c.hibernateRepo, + } + c.rootCmd.Subcommands["repo"] = repoCmd // Backward compatibility aliases for root-level repo commands @@ -625,6 +641,13 @@ func (c *CLI) registerCommands() { Run: c.repair, } + c.rootCmd.Subcommands["refresh"] = &Command{ + Name: "refresh", + Description: "Sync agent worktrees with main branch", + Usage: "multiclaude refresh", + Run: c.refresh, + } + // Claude restart command - for resuming Claude after exit c.rootCmd.Subcommands["claude"] = &Command{ Name: "claude", @@ -698,6 +721,14 @@ func (c *CLI) registerCommands() { Run: c.bugReport, } + // Diagnostics command + c.rootCmd.Subcommands["diagnostics"] = &Command{ + Name: "diagnostics", + Description: "Show system diagnostics in machine-readable format", + Usage: "multiclaude diagnostics [--json] [--output ]", + Run: c.diagnostics, + } + // Version command c.rootCmd.Subcommands["version"] = &Command{ Name: "version", @@ -801,6 +832,112 @@ func (c *CLI) daemonStatus(args []string) error { return nil } +// systemStatus shows a comprehensive system overview that gracefully handles +// the daemon not running (unlike list commands which error). +func (c *CLI) systemStatus(args []string) error { + // Check PID file first + pidFile := daemon.NewPIDFile(c.paths.DaemonPID) + running, pid, err := pidFile.IsRunning() + if err != nil { + return fmt.Errorf("failed to check daemon status: %w", err) + } + + if !running { + format.Header("Multiclaude Status") + fmt.Println() + fmt.Printf(" Daemon: %s\n", format.Red.Sprint("not running")) + fmt.Println() + format.Dimmed("Start with: multiclaude daemon start") + return nil + } + + // Try to connect to daemon and get rich status + client := socket.NewClient(c.paths.DaemonSock) + resp, err := client.Send(socket.Request{ + Command: "list_repos", + Args: map[string]interface{}{"rich": true}, + }) + + if err != nil { + format.Header("Multiclaude Status") + fmt.Println() + fmt.Printf(" Daemon: %s (PID: %d, not responding)\n", format.Yellow.Sprint("unhealthy"), pid) + fmt.Println() + format.Dimmed("Try: multiclaude daemon stop && multiclaude daemon start") + return nil + } + + if !resp.Success { + format.Header("Multiclaude Status") + fmt.Println() + fmt.Printf(" Daemon: %s (PID: %d)\n", format.Yellow.Sprint("error"), pid) + fmt.Printf(" Error: %s\n", resp.Error) + return nil + } + + // Print status header + format.Header("Multiclaude Status") + fmt.Println() + fmt.Printf(" Daemon: %s (PID: %d)\n", format.Green.Sprint("running"), pid) + + repos, ok := resp.Data.([]interface{}) + if !ok || len(repos) == 0 { + fmt.Printf(" Repos: %s\n", format.Dim.Sprint("none")) + fmt.Println() + format.Dimmed("Initialize a repo with: multiclaude init ") + return nil + } + + fmt.Printf(" Repos: %d\n", len(repos)) + fmt.Println() + + // Show each repo with agents + for _, repo := range repos { + repoMap, ok := repo.(map[string]interface{}) + if !ok { + continue + } + + name, _ := repoMap["name"].(string) + totalAgents := 0 + if v, ok := repoMap["total_agents"].(float64); ok { + totalAgents = int(v) + } + workerCount := 0 + if v, ok := repoMap["worker_count"].(float64); ok { + workerCount = int(v) + } + sessionHealthy, _ := repoMap["session_healthy"].(bool) + + // Repo line + repoStatus := format.Green.Sprint("●") + if !sessionHealthy { + repoStatus = format.Yellow.Sprint("○") + } + fmt.Printf(" %s %s\n", repoStatus, format.Bold.Sprint(name)) + + // Agent summary + coreAgents := totalAgents - workerCount + if coreAgents < 0 { + coreAgents = 0 + } + fmt.Printf(" Agents: %d core, %d workers\n", coreAgents, workerCount) + + // Show fork info if applicable + if isFork, _ := repoMap["is_fork"].(bool); isFork { + upstreamOwner, _ := repoMap["upstream_owner"].(string) + upstreamRepo, _ := repoMap["upstream_repo"].(string) + if upstreamOwner != "" && upstreamRepo != "" { + fmt.Printf(" Fork of: %s/%s\n", upstreamOwner, upstreamRepo) + } + } + } + + fmt.Println() + format.Dimmed("Details: multiclaude repo list | multiclaude worker list") + return nil +} + func (c *CLI) daemonLogs(args []string) error { flags, _ := ParseFlags(args) @@ -1082,13 +1219,42 @@ func (c *CLI) initRepo(args []string) error { // Check if daemon is running client := socket.NewClient(c.paths.DaemonSock) - _, err := client.Send(socket.Request{Command: "ping"}) - if err != nil { + if _, err := client.Send(socket.Request{Command: "ping"}); err != nil { return errors.DaemonNotRunning() } - // Clone repository + // Check if repository is already initialized + st, err := state.Load(c.paths.StateFile) + if err != nil { + return fmt.Errorf("failed to load state: %w", err) + } + if _, exists := st.GetRepo(repoName); exists { + return fmt.Errorf("repository '%s' is already initialized\nUse 'multiclaude repo rm %s' to remove it first, or choose a different name", repoName, repoName) + } + + // Check if tmux session already exists (stale session from previous incomplete init) + tmuxSession := sanitizeTmuxSessionName(repoName) + if tmuxSession == "mc-" { + return fmt.Errorf("invalid tmux session name: repository name cannot be empty") + } + tmuxClient := tmux.NewClient() + if exists, err := tmuxClient.HasSession(context.Background(), tmuxSession); err == nil && exists { + fmt.Printf("Warning: Tmux session '%s' already exists\n", tmuxSession) + fmt.Printf("This may be from a previous incomplete initialization.\n") + fmt.Printf("Auto-repairing: killing existing tmux session...\n") + if err := tmuxClient.KillSession(context.Background(), tmuxSession); err != nil { + return fmt.Errorf("failed to clean up existing tmux session: %w\nPlease manually kill it with: tmux kill-session -t %s", err, tmuxSession) + } + fmt.Println("✓ Cleaned up stale tmux session") + } + + // Check if repository directory already exists repoPath := c.paths.RepoDir(repoName) + if _, err := os.Stat(repoPath); err == nil { + return fmt.Errorf("directory already exists: %s\nRemove it manually or choose a different name", repoPath) + } + + // Clone repository fmt.Printf("Cloning to: %s\n", repoPath) cmd := exec.Command("git", "clone", githubURL, repoPath) @@ -1140,12 +1306,7 @@ func (c *CLI) initRepo(args []string) error { return fmt.Errorf("failed to copy agent templates: %w", err) } - // Create tmux session - tmuxSession := sanitizeTmuxSessionName(repoName) - if tmuxSession == "mc-" { - return fmt.Errorf("invalid tmux session name: repository name cannot be empty") - } - + // Create tmux session (tmuxSession already defined and validated earlier) fmt.Printf("Creating tmux session: %s\n", tmuxSession) // Create session with supervisor window @@ -1932,10 +2093,42 @@ func (c *CLI) createWorker(args []string) error { return errors.NotInRepo() } - // Generate worker name (Docker-style) - workerName := names.Generate() + // Get existing agents to ensure unique naming + client := socket.NewClient(c.paths.DaemonSock) + resp, err := client.Send(socket.Request{ + Command: "list_agents", + Args: map[string]interface{}{ + "repo": repoName, + }, + }) + if err != nil { + return errors.DaemonCommunicationFailed("getting existing agents", err) + } + if !resp.Success { + return errors.Wrap(errors.CategoryRuntime, "failed to get existing agents", fmt.Errorf("%s", resp.Error)) + } + + // Extract existing worker names for uniqueness check + var existingNames []string + if agents, ok := resp.Data.([]interface{}); ok { + for _, agent := range agents { + if agentMap, ok := agent.(map[string]interface{}); ok { + if agentName, ok := agentMap["name"].(string); ok { + existingNames = append(existingNames, agentName) + } + } + } + } + + // Generate worker name from task description + var workerName string if name, ok := flags["name"]; ok { + // Manual override via --name flag workerName = name + } else { + // Generate task-based name and ensure uniqueness + workerName = names.FromTask(task) + workerName = names.EnsureUnique(workerName, existingNames) } // Check for --push-to flag (for iterating on existing PRs) @@ -2022,8 +2215,8 @@ func (c *CLI) createWorker(args []string) error { } // Get repository info to determine tmux session - client := socket.NewClient(c.paths.DaemonSock) - resp, err := client.Send(socket.Request{ + client = socket.NewClient(c.paths.DaemonSock) + resp, err = client.Send(socket.Request{ Command: "list_agents", Args: map[string]interface{}{ "repo": repoName, @@ -2828,6 +3021,245 @@ func (c *CLI) removeWorker(args []string) error { return nil } +// hibernateRepo stops all work in a repository and archives uncommitted changes +func (c *CLI) hibernateRepo(args []string) error { + flags, _ := ParseFlags(args) + skipConfirm := flags["yes"] == "true" + hibernateAll := flags["all"] == "true" // Also hibernate persistent agents (supervisor, workspace) + + // Determine repository + repoName, err := c.resolveRepo(flags) + if err != nil { + return errors.NotInRepo() + } + + // Get agent list from daemon + client := socket.NewClient(c.paths.DaemonSock) + resp, err := client.Send(socket.Request{ + Command: "list_agents", + Args: map[string]interface{}{ + "repo": repoName, + }, + }) + if err != nil { + return errors.DaemonCommunicationFailed("getting agent info", err) + } + if !resp.Success { + return errors.Wrap(errors.CategoryRuntime, "failed to get agent info", fmt.Errorf("%s", resp.Error)) + } + + agents, _ := resp.Data.([]interface{}) + if len(agents) == 0 { + fmt.Printf("No agents running in repository '%s'\n", repoName) + return nil + } + + // Filter agents to hibernate (workers, review agents; optionally all) + var agentsToHibernate []map[string]interface{} + var agentsWithChanges []map[string]interface{} + + for _, agent := range agents { + agentMap, ok := agent.(map[string]interface{}) + if !ok { + continue + } + + agentType, _ := agentMap["type"].(string) + wtPath, _ := agentMap["worktree_path"].(string) + + // Determine if this agent should be hibernated + shouldHibernate := false + switch agentType { + case "worker", "review": + shouldHibernate = true + case "supervisor", "merge-queue", "pr-shepherd", "workspace", "generic-persistent": + shouldHibernate = hibernateAll + } + + if !shouldHibernate { + continue + } + + agentsToHibernate = append(agentsToHibernate, agentMap) + + // Check for uncommitted changes + if wtPath != "" { + hasUncommitted, err := worktree.HasUncommittedChanges(wtPath) + if err == nil && hasUncommitted { + agentsWithChanges = append(agentsWithChanges, agentMap) + } + } + } + + if len(agentsToHibernate) == 0 { + fmt.Printf("No agents to hibernate in repository '%s'\n", repoName) + if !hibernateAll { + fmt.Println("Use --all to also hibernate persistent agents (supervisor, workspace, etc.)") + } + return nil + } + + // Show summary and confirm + fmt.Printf("Hibernating %d agent(s) in repository '%s':\n", len(agentsToHibernate), repoName) + for _, agent := range agentsToHibernate { + name, _ := agent["name"].(string) + agentType, _ := agent["type"].(string) + hasChanges := false + for _, changed := range agentsWithChanges { + if changed["name"] == name { + hasChanges = true + break + } + } + changeMarker := "" + if hasChanges { + changeMarker = " [has uncommitted changes]" + } + fmt.Printf(" - %s (%s)%s\n", name, agentType, changeMarker) + } + + if len(agentsWithChanges) > 0 { + fmt.Printf("\n%d agent(s) have uncommitted changes that will be archived.\n", len(agentsWithChanges)) + } + + if !skipConfirm { + fmt.Print("\nContinue? [y/N]: ") + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" { + fmt.Println("Cancelled") + return nil + } + } + + // Create archive directory with timestamp + timestamp := time.Now().Format("2006-01-02_15-04-05") + archiveDir := filepath.Join(c.paths.RepoArchiveDir(repoName), timestamp) + if len(agentsWithChanges) > 0 { + if err := os.MkdirAll(archiveDir, 0755); err != nil { + return fmt.Errorf("failed to create archive directory: %w", err) + } + fmt.Printf("\nArchiving to: %s\n", archiveDir) + } + + // Archive uncommitted changes + var archivedAgents []string + for _, agent := range agentsWithChanges { + name, _ := agent["name"].(string) + wtPath, _ := agent["worktree_path"].(string) + branch, _ := agent["branch"].(string) + task, _ := agent["task"].(string) + + fmt.Printf("Archiving changes from %s...\n", name) + + // Create patch file with git diff + patchPath := filepath.Join(archiveDir, name+".patch") + cmd := exec.Command("git", "diff", "HEAD") + cmd.Dir = wtPath + output, err := cmd.Output() + if err != nil { + fmt.Printf("Warning: failed to create patch for %s: %v\n", name, err) + continue + } + + // Include untracked files in the patch + untrackedCmd := exec.Command("git", "ls-files", "--others", "--exclude-standard") + untrackedCmd.Dir = wtPath + untrackedOutput, _ := untrackedCmd.Output() + + // Write patch file + if err := os.WriteFile(patchPath, output, 0644); err != nil { + fmt.Printf("Warning: failed to write patch for %s: %v\n", name, err) + continue + } + + // Write untracked files list if any + if len(untrackedOutput) > 0 { + untrackedPath := filepath.Join(archiveDir, name+".untracked") + os.WriteFile(untrackedPath, untrackedOutput, 0644) + } + + // Write metadata for this agent + metaPath := filepath.Join(archiveDir, name+".json") + meta := map[string]interface{}{ + "name": name, + "type": agent["type"], + "branch": branch, + "task": task, + "worktree_path": wtPath, + "archived_at": time.Now().Format(time.RFC3339), + } + metaData, _ := json.MarshalIndent(meta, "", " ") + os.WriteFile(metaPath, metaData, 0644) + + archivedAgents = append(archivedAgents, name) + } + + // Write summary metadata + if len(agentsWithChanges) > 0 { + summaryPath := filepath.Join(archiveDir, "hibernate-summary.json") + summary := map[string]interface{}{ + "repo": repoName, + "hibernated_at": time.Now().Format(time.RFC3339), + "agents_hibernated": len(agentsToHibernate), + "agents_archived": archivedAgents, + } + summaryData, _ := json.MarshalIndent(summary, "", " ") + os.WriteFile(summaryPath, summaryData, 0644) + } + + // Stop agents + tmuxSession := sanitizeTmuxSessionName(repoName) + repoPath := c.paths.RepoDir(repoName) + wt := worktree.NewManager(repoPath) + + fmt.Println() + for _, agent := range agentsToHibernate { + name, _ := agent["name"].(string) + wtPath, _ := agent["worktree_path"].(string) + tmuxWindow, _ := agent["tmux_window"].(string) + + fmt.Printf("Stopping %s...\n", name) + + // Kill tmux window + if tmuxWindow != "" { + cmd := exec.Command("tmux", "kill-window", "-t", fmt.Sprintf("%s:%s", tmuxSession, tmuxWindow)) + cmd.Run() // Ignore errors + } + + // Remove worktree (force since we archived changes) + if wtPath != "" { + if err := wt.Remove(wtPath, true); err != nil { + // Try harder with force + cmd := exec.Command("git", "worktree", "remove", "--force", wtPath) + cmd.Dir = repoPath + cmd.Run() + } + } + + // Unregister from daemon (ignore errors during cleanup) + _, _ = client.Send(socket.Request{ + Command: "remove_agent", + Args: map[string]interface{}{ + "repo": repoName, + "agent": name, + }, + }) + } + + fmt.Println() + fmt.Printf("✓ Hibernated %d agent(s) in '%s'\n", len(agentsToHibernate), repoName) + if len(archivedAgents) > 0 { + fmt.Printf("✓ Archived %d agent(s) with uncommitted changes to:\n", len(archivedAgents)) + fmt.Printf(" %s\n", archiveDir) + fmt.Println("\nTo restore archived patches:") + fmt.Println(" cd ") + fmt.Printf(" git apply %s/.patch\n", archiveDir) + } + + return nil +} + // Workspace command implementations // workspaceDefault handles `multiclaude workspace` with no subcommand or `multiclaude workspace ` @@ -2910,6 +3342,17 @@ func (c *CLI) addWorkspace(args []string) error { wtPath := c.paths.AgentWorktree(repoName, workspaceName) branchName := fmt.Sprintf("workspace/%s", workspaceName) + // Check if worktree path already exists (from previous incomplete workspace add) + if _, err := os.Stat(wtPath); err == nil { + fmt.Printf("Warning: Worktree path '%s' already exists\n", wtPath) + fmt.Printf("This may be from a previous incomplete workspace creation.\n") + fmt.Printf("Auto-repairing: removing existing worktree...\n") + if err := wt.Remove(wtPath, true); err != nil { + return fmt.Errorf("failed to clean up existing worktree: %w\nPlease manually remove it with: git worktree remove %s", err, wtPath) + } + fmt.Println("✓ Cleaned up stale worktree") + } + fmt.Printf("Creating worktree at: %s\n", wtPath) if err := wt.CreateNewBranch(wtPath, branchName, startBranch); err != nil { return errors.WorktreeCreationFailed(err) @@ -2918,6 +3361,18 @@ func (c *CLI) addWorkspace(args []string) error { // Get tmux session name tmuxSession := sanitizeTmuxSessionName(repoName) + // Check if tmux window already exists (stale window from previous incomplete workspace add) + tmuxClient := tmux.NewClient() + if exists, err := tmuxClient.HasWindow(context.Background(), tmuxSession, workspaceName); err == nil && exists { + fmt.Printf("Warning: Tmux window '%s' already exists in session '%s'\n", workspaceName, tmuxSession) + fmt.Printf("This may be from a previous incomplete workspace creation.\n") + fmt.Printf("Auto-repairing: killing existing tmux window...\n") + if err := tmuxClient.KillWindow(context.Background(), tmuxSession, workspaceName); err != nil { + return fmt.Errorf("failed to clean up existing tmux window: %w\nPlease manually kill it with: tmux kill-window -t %s:%s", err, tmuxSession, workspaceName) + } + fmt.Println("✓ Cleaned up stale tmux window") + } + // Create tmux window for workspace (detached so it doesn't switch focus) fmt.Printf("Creating tmux window: %s\n", workspaceName) cmd := exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", workspaceName, "-c", wtPath) @@ -4877,6 +5332,34 @@ func (c *CLI) repair(args []string) error { return nil } +// refresh triggers an immediate worktree sync for all agents +func (c *CLI) refresh(args []string) error { + // Connect to daemon + client := socket.NewClient(c.paths.DaemonSock) + _, err := client.Send(socket.Request{Command: "ping"}) + if err != nil { + return errors.DaemonNotRunning() + } + + fmt.Println("Triggering worktree refresh...") + + resp, err := client.Send(socket.Request{ + Command: "trigger_refresh", + }) + if err != nil { + return fmt.Errorf("failed to trigger refresh: %w", err) + } + if !resp.Success { + return fmt.Errorf("refresh failed: %s", resp.Error) + } + + fmt.Println("✓ Worktree refresh triggered") + fmt.Println(" Agent worktrees will be synced with main branch in the background.") + fmt.Println(" Agents will receive a notification when their worktree is refreshed.") + + return nil +} + // localRepair performs state repair without the daemon running func (c *CLI) localRepair(verbose bool) error { // Load state from disk @@ -5497,6 +5980,38 @@ func (c *CLI) bugReport(args []string) error { return nil } +// diagnostics generates system diagnostics in machine-readable format +func (c *CLI) diagnostics(args []string) error { + flags, _ := ParseFlags(args) + + // Create collector and generate report + collector := diagnostics.NewCollector(c.paths, Version) + report, err := collector.Collect() + if err != nil { + return fmt.Errorf("failed to collect diagnostics: %w", err) + } + + // Always output as pretty JSON by default (unless --json=false for compact) + prettyJSON := flags["json"] != "false" + jsonOutput, err := report.ToJSON(prettyJSON) + if err != nil { + return fmt.Errorf("failed to format diagnostics as JSON: %w", err) + } + + // Check if output file specified + if outputFile, ok := flags["output"]; ok { + if err := os.WriteFile(outputFile, []byte(jsonOutput), 0644); err != nil { + return fmt.Errorf("failed to write diagnostics to %s: %w", outputFile, err) + } + fmt.Printf("Diagnostics written to: %s\n", outputFile) + return nil + } + + // Print to stdout + fmt.Println(jsonOutput) + return nil +} + // listBranchesWithPrefix returns all local branches with the given prefix func (c *CLI) listBranchesWithPrefix(repoPath, prefix string) ([]string, error) { cmd := exec.Command("git", "branch", "--list", prefix+"*") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9224905..498577d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -316,6 +316,7 @@ func setupTestEnvironment(t *testing.T) (*CLI, *daemon.Daemon, func()) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -399,6 +400,51 @@ func TestCLIDaemonStatus(t *testing.T) { } } +func TestCLISystemStatusWithDaemon(t *testing.T) { + cli, d, cleanup := setupTestEnvironment(t) + defer cleanup() + + // System status with daemon running but no repos + err := cli.Execute([]string{"status"}) + if err != nil { + t.Errorf("system status failed: %v", err) + } + + // Add a repo and check again + 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) + } + + // System status should show the repo + err = cli.Execute([]string{"status"}) + if err != nil { + t.Errorf("system status with repo failed: %v", err) + } +} + +func TestCLISystemStatusWithoutDaemon(t *testing.T) { + // Create CLI without starting daemon + tmpDir, err := os.MkdirTemp("", "cli-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + paths := config.NewTestPaths(tmpDir) + cli := NewWithPaths(paths) + + // System status should NOT error when daemon not running + err = cli.Execute([]string{"status"}) + if err != nil { + t.Errorf("system status should not error when daemon not running: %v", err) + } +} + func TestCLIWorkListEmpty(t *testing.T) { cli, d, cleanup := setupTestEnvironment(t) defer cleanup() @@ -662,6 +708,7 @@ func TestCLISendMessageFallbackWhenDaemonUnavailable(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -1044,6 +1091,7 @@ func TestNewWithPaths(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } // Test CLI creation diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index d64b918..c755412 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -13,6 +13,7 @@ import ( "time" "github.com/dlorenc/multiclaude/internal/agents" + "github.com/dlorenc/multiclaude/internal/diagnostics" "github.com/dlorenc/multiclaude/internal/hooks" "github.com/dlorenc/multiclaude/internal/logging" "github.com/dlorenc/multiclaude/internal/messages" @@ -97,6 +98,9 @@ func (d *Daemon) Start() error { d.logger.Info("Daemon started successfully") + // Log system diagnostics for monitoring and debugging + d.logDiagnostics() + // Restore agents for tracked repos BEFORE starting health checks // This prevents race conditions where health check cleans up agents being restored d.restoreTrackedRepos() @@ -142,6 +146,27 @@ func (d *Daemon) TriggerWake() { d.wakeAgents() } +// logDiagnostics logs system diagnostics in machine-readable JSON format +func (d *Daemon) logDiagnostics() { + // Get version from CLI package (same as used by CLI) + version := "dev" + + collector := diagnostics.NewCollector(d.paths, version) + report, err := collector.Collect() + if err != nil { + d.logger.Error("Failed to collect diagnostics: %v", err) + return + } + + jsonOutput, err := report.ToJSON(false) // Compact JSON for logs + if err != nil { + d.logger.Error("Failed to format diagnostics: %v", err) + return + } + + d.logger.Info("System diagnostics: %s", jsonOutput) +} + // Stop stops the daemon func (d *Daemon) Stop() error { d.logger.Info("Stopping daemon") @@ -176,14 +201,29 @@ func (d *Daemon) Stop() error { func getRequiredStringArg(args map[string]interface{}, key, description string) (string, socket.Response, bool) { val, ok := args[key].(string) if !ok || val == "" { - return "", socket.Response{ - Success: false, - Error: fmt.Sprintf("missing '%s': %s", key, description), - }, false + return "", socket.ErrorResponse("missing '%s': %s", key, description), false } return val, socket.Response{}, true } +// getOptionalStringArg extracts an optional string argument from request Args. +// Returns the value if present, or the default value if missing. +func getOptionalStringArg(args map[string]interface{}, key, defaultVal string) string { + if val, ok := args[key].(string); ok { + return val + } + return defaultVal +} + +// getOptionalBoolArg extracts an optional bool argument from request Args. +// Returns the value if present, or the default value if missing. +func getOptionalBoolArg(args map[string]interface{}, key string, defaultVal bool) bool { + if val, ok := args[key].(bool); ok { + return val + } + return defaultVal +} + // periodicLoop runs a function periodically at the specified interval. // If onStartup is provided, it's called immediately before entering the loop. // The onTick function is called on each timer tick. @@ -577,7 +617,7 @@ func (d *Daemon) handleRequest(req socket.Request) socket.Response { switch req.Command { case "ping": - return socket.Response{Success: true, Data: "pong"} + return socket.SuccessResponse("pong") case "status": return d.handleStatus(req) @@ -587,7 +627,7 @@ func (d *Daemon) handleRequest(req socket.Request) socket.Response { time.Sleep(100 * time.Millisecond) d.Stop() }() - return socket.Response{Success: true, Data: "Daemon stopping"} + return socket.SuccessResponse("Daemon stopping") case "list_repos": return d.handleListRepos(req) @@ -636,7 +676,7 @@ func (d *Daemon) handleRequest(req socket.Request) socket.Response { case "route_messages": go d.routeMessages() - return socket.Response{Success: true, Data: "Message routing triggered"} + return socket.SuccessResponse("Message routing triggered") case "task_history": return d.handleTaskHistory(req) @@ -644,11 +684,11 @@ func (d *Daemon) handleRequest(req socket.Request) socket.Response { case "spawn_agent": return d.handleSpawnAgent(req) + case "trigger_refresh": + return d.handleTriggerRefresh(req) + default: - return socket.Response{ - Success: false, - Error: fmt.Sprintf("unknown command: %q. Run 'multiclaude --help' for available commands", req.Command), - } + return socket.ErrorResponse("unknown command: %q. Run 'multiclaude --help' for available commands", req.Command) } } @@ -661,16 +701,13 @@ func (d *Daemon) handleStatus(req socket.Request) socket.Response { agentCount += len(agents) } - return socket.Response{ - Success: true, - Data: map[string]interface{}{ - "running": true, - "pid": os.Getpid(), - "repos": len(repos), - "agents": agentCount, - "socket_path": d.paths.DaemonSock, - }, - } + return socket.SuccessResponse(map[string]interface{}{ + "running": true, + "pid": os.Getpid(), + "repos": len(repos), + "agents": agentCount, + "socket_path": d.paths.DaemonSock, + }) } // handleListRepos lists all repositories with detailed status @@ -678,14 +715,14 @@ func (d *Daemon) handleListRepos(req socket.Request) socket.Response { repos := d.state.GetAllRepos() // Check if rich format is requested - rich, _ := req.Args["rich"].(bool) + rich := getOptionalBoolArg(req.Args, "rich", false) if !rich { // Return simple list for backward compatibility repoNames := make([]string, 0, len(repos)) for name := range repos { repoNames = append(repoNames, name) } - return socket.Response{Success: true, Data: repoNames} + return socket.SuccessResponse(repoNames) } // Return detailed repo info @@ -726,7 +763,7 @@ func (d *Daemon) handleListRepos(req socket.Request) socket.Response { }) } - return socket.Response{Success: true, Data: repoDetails} + return socket.SuccessResponse(repoDetails) } // handleAddRepo adds a new repository @@ -748,41 +785,34 @@ func (d *Daemon) handleAddRepo(req socket.Request) socket.Response { // Parse merge queue configuration (optional, defaults to enabled with "all" tracking) mqConfig := state.DefaultMergeQueueConfig() - if mqEnabled, ok := req.Args["mq_enabled"].(bool); ok { + if mqEnabled, hasMqEnabled := req.Args["mq_enabled"].(bool); hasMqEnabled { mqConfig.Enabled = mqEnabled } - if mqTrackMode, ok := req.Args["mq_track_mode"].(string); ok { + if mqTrackMode := getOptionalStringArg(req.Args, "mq_track_mode", ""); mqTrackMode != "" { mode, err := state.ParseTrackMode(mqTrackMode) if err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } mqConfig.TrackMode = mode } // Parse fork configuration (optional) - var forkConfig state.ForkConfig - if isFork, ok := req.Args["is_fork"].(bool); ok { - forkConfig.IsFork = isFork - } - if upstreamURL, ok := req.Args["upstream_url"].(string); ok { - forkConfig.UpstreamURL = upstreamURL - } - if upstreamOwner, ok := req.Args["upstream_owner"].(string); ok { - forkConfig.UpstreamOwner = upstreamOwner - } - if upstreamRepo, ok := req.Args["upstream_repo"].(string); ok { - forkConfig.UpstreamRepo = upstreamRepo + forkConfig := state.ForkConfig{ + IsFork: getOptionalBoolArg(req.Args, "is_fork", false), + UpstreamURL: getOptionalStringArg(req.Args, "upstream_url", ""), + UpstreamOwner: getOptionalStringArg(req.Args, "upstream_owner", ""), + UpstreamRepo: getOptionalStringArg(req.Args, "upstream_repo", ""), } // Parse PR shepherd configuration (optional, defaults for fork mode) psConfig := state.DefaultPRShepherdConfig() - if psEnabled, ok := req.Args["ps_enabled"].(bool); ok { + if psEnabled, hasPsEnabled := req.Args["ps_enabled"].(bool); hasPsEnabled { psConfig.Enabled = psEnabled } - if psTrackMode, ok := req.Args["ps_track_mode"].(string); ok { + if psTrackMode := getOptionalStringArg(req.Args, "ps_track_mode", ""); psTrackMode != "" { mode, err := state.ParseTrackMode(psTrackMode) if err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } psConfig.TrackMode = mode } @@ -803,7 +833,7 @@ func (d *Daemon) handleAddRepo(req socket.Request) socket.Response { } if err := d.state.AddRepo(name, repo); err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } if forkConfig.IsFork { @@ -811,7 +841,7 @@ func (d *Daemon) handleAddRepo(req socket.Request) socket.Response { } else { d.logger.Info("Added repository: %s (merge queue: enabled=%v, track=%s)", name, mqConfig.Enabled, mqConfig.TrackMode) } - return socket.Response{Success: true} + return socket.SuccessResponse(nil) } // handleRemoveRepo removes a repository from state @@ -822,11 +852,11 @@ func (d *Daemon) handleRemoveRepo(req socket.Request) socket.Response { } if err := d.state.RemoveRepo(name); err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } d.logger.Info("Removed repository: %s", name) - return socket.Response{Success: true} + return socket.SuccessResponse(nil) } // handleAddAgent adds a new agent @@ -880,16 +910,14 @@ func (d *Daemon) handleAddAgent(req socket.Request) socket.Response { } // Optional task field for workers - if task, ok := req.Args["task"].(string); ok { - agent.Task = task - } + agent.Task = getOptionalStringArg(req.Args, "task", "") if err := d.state.AddAgent(repoName, agentName, agent); err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } d.logger.Info("Added agent %s to repo %s", agentName, repoName) - return socket.Response{Success: true} + return socket.SuccessResponse(nil) } // handleRemoveAgent removes an agent @@ -905,11 +933,11 @@ func (d *Daemon) handleRemoveAgent(req socket.Request) socket.Response { } if err := d.state.RemoveAgent(repoName, agentName); err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } d.logger.Info("Removed agent %s from repo %s", agentName, repoName) - return socket.Response{Success: true} + return socket.SuccessResponse(nil) } // handleListAgents lists agents for a repository @@ -921,11 +949,11 @@ func (d *Daemon) handleListAgents(req socket.Request) socket.Response { agents, err := d.state.ListAgents(repoName) if err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } // Check if rich format is requested - rich, _ := req.Args["rich"].(bool) + rich := getOptionalBoolArg(req.Args, "rich", false) // Get repository to check session repo, repoExists := d.state.GetRepo(repoName) @@ -989,7 +1017,7 @@ func (d *Daemon) handleListAgents(req socket.Request) socket.Response { agentDetails = append(agentDetails, detail) } - return socket.Response{Success: true, Data: agentDetails} + return socket.SuccessResponse(agentDetails) } // handleCompleteAgent marks an agent as ready for cleanup @@ -1006,22 +1034,22 @@ func (d *Daemon) handleCompleteAgent(req socket.Request) socket.Response { agent, exists := d.state.GetAgent(repoName, agentName) if !exists { - return socket.Response{Success: false, Error: fmt.Sprintf("agent '%s' not found in repository '%s' - check available agents with: multiclaude worker list --repo %s", agentName, repoName, repoName)} + return socket.ErrorResponse("agent '%s' not found in repository '%s' - check available agents with: multiclaude worker list --repo %s", agentName, repoName, repoName) } // Mark as ready for cleanup agent.ReadyForCleanup = true // Optional: capture summary and failure reason for task history - if summary, ok := req.Args["summary"].(string); ok && summary != "" { + if summary := getOptionalStringArg(req.Args, "summary", ""); summary != "" { agent.Summary = summary } - if failureReason, ok := req.Args["failure_reason"].(string); ok && failureReason != "" { + if failureReason := getOptionalStringArg(req.Args, "failure_reason", ""); failureReason != "" { agent.FailureReason = failureReason } if err := d.state.UpdateAgent(repoName, agentName, agent); err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } d.logger.Info("Agent %s/%s marked as ready for cleanup", repoName, agentName) @@ -1067,7 +1095,7 @@ func (d *Daemon) handleCompleteAgent(req socket.Request) socket.Response { // Trigger immediate cleanup check go d.checkAgentHealth() - return socket.Response{Success: true} + return socket.SuccessResponse(nil) } // handleRestartAgent restarts an agent that has crashed or exited @@ -1082,56 +1110,53 @@ func (d *Daemon) handleRestartAgent(req socket.Request) socket.Response { return errResp } - force, _ := req.Args["force"].(bool) + force := getOptionalBoolArg(req.Args, "force", false) agent, exists := d.state.GetAgent(repoName, agentName) if !exists { - return socket.Response{Success: false, Error: fmt.Sprintf("agent '%s' not found in repository '%s' - check available agents with: multiclaude worker list --repo %s", agentName, repoName, repoName)} + return socket.ErrorResponse("agent '%s' not found in repository '%s' - check available agents with: multiclaude worker list --repo %s", agentName, repoName, repoName) } // Check if agent is marked for cleanup (completed) if agent.ReadyForCleanup { - return socket.Response{Success: false, Error: fmt.Sprintf("agent '%s' is marked as complete and pending cleanup - cannot restart a completed agent", agentName)} + return socket.ErrorResponse("agent '%s' is marked as complete and pending cleanup - cannot restart a completed agent", agentName) } // Check if tmux window exists repo, exists := d.state.GetRepo(repoName) if !exists { - return socket.Response{Success: false, Error: fmt.Sprintf("repository '%s' not found in state", repoName)} + return socket.ErrorResponse("repository '%s' not found in state", repoName) } hasWindow, err := d.tmux.HasWindow(d.ctx, repo.TmuxSession, agentName) if err != nil { - return socket.Response{Success: false, Error: fmt.Sprintf("failed to check tmux window: %v", err)} + return socket.ErrorResponse("failed to check tmux window: %v", err) } if !hasWindow { - return socket.Response{Success: false, Error: fmt.Sprintf("tmux window '%s' does not exist - the agent may need to be recreated", agentName)} + return socket.ErrorResponse("tmux window '%s' does not exist - the agent may need to be recreated", agentName) } // Check if agent is already running if agent.PID > 0 && isProcessAlive(agent.PID) { if !force { - return socket.Response{Success: false, Error: fmt.Sprintf("agent '%s' is already running with PID %d - use --force to restart anyway", agentName, agent.PID)} + return socket.ErrorResponse("agent '%s' is already running with PID %d - use --force to restart anyway", agentName, agent.PID) } d.logger.Info("Force restarting agent %s (PID %d was still running)", agentName, agent.PID) } // Restart the agent if err := d.restartAgent(repoName, agentName, agent, repo); err != nil { - return socket.Response{Success: false, Error: fmt.Sprintf("failed to restart agent: %v", err)} + return socket.ErrorResponse("failed to restart agent: %v", err) } // Get updated PID from state updatedAgent, _ := d.state.GetAgent(repoName, agentName) - return socket.Response{ - Success: true, - Data: map[string]interface{}{ - "agent": agentName, - "repo": repoName, - "pid": updatedAgent.PID, - "message": fmt.Sprintf("Agent '%s' restarted successfully", agentName), - }, - } + return socket.SuccessResponse(map[string]interface{}{ + "agent": agentName, + "repo": repoName, + "pid": updatedAgent.PID, + "message": fmt.Sprintf("Agent '%s' restarted successfully", agentName), + }) } // handleTriggerCleanup manually triggers cleanup operations @@ -1141,10 +1166,17 @@ func (d *Daemon) handleTriggerCleanup(req socket.Request) socket.Response { // Run health check to find dead agents d.checkAgentHealth() - return socket.Response{ - Success: true, - Data: "Cleanup triggered", - } + return socket.SuccessResponse("Cleanup triggered") +} + +// handleTriggerRefresh manually triggers worktree refresh for all agents +func (d *Daemon) handleTriggerRefresh(req socket.Request) socket.Response { + d.logger.Info("Manual worktree refresh triggered") + + // Run refresh in background so we can return immediately + go d.refreshWorktrees() + + return socket.SuccessResponse("Worktree refresh triggered") } // handleRepairState repairs state inconsistencies @@ -1215,13 +1247,10 @@ func (d *Daemon) handleRepairState(req socket.Request) socket.Response { d.logger.Info("State repair completed: %d agents removed, %d issues fixed", agentsRemoved, issuesFixed) - return socket.Response{ - Success: true, - Data: map[string]interface{}{ - "agents_removed": agentsRemoved, - "issues_fixed": issuesFixed, - }, - } + return socket.SuccessResponse(map[string]interface{}{ + "agents_removed": agentsRemoved, + "issues_fixed": issuesFixed, + }) } // handleGetRepoConfig returns the configuration for a repository @@ -1233,7 +1262,7 @@ func (d *Daemon) handleGetRepoConfig(req socket.Request) socket.Response { repo, exists := d.state.GetRepo(name) if !exists { - return socket.Response{Success: false, Error: fmt.Sprintf("repository %q not found", name)} + return socket.ErrorResponse("repository %q not found", name) } // Get merge queue config (use default if not set for backward compatibility) @@ -1251,20 +1280,17 @@ func (d *Daemon) handleGetRepoConfig(req socket.Request) socket.Response { // Get fork config forkConfig := repo.ForkConfig - return socket.Response{ - Success: true, - Data: map[string]interface{}{ - "mq_enabled": mqConfig.Enabled, - "mq_track_mode": string(mqConfig.TrackMode), - "ps_enabled": psConfig.Enabled, - "ps_track_mode": string(psConfig.TrackMode), - "is_fork": forkConfig.IsFork, - "upstream_url": forkConfig.UpstreamURL, - "upstream_owner": forkConfig.UpstreamOwner, - "upstream_repo": forkConfig.UpstreamRepo, - "force_fork_mode": forkConfig.ForceForkMode, - }, - } + return socket.SuccessResponse(map[string]interface{}{ + "mq_enabled": mqConfig.Enabled, + "mq_track_mode": string(mqConfig.TrackMode), + "ps_enabled": psConfig.Enabled, + "ps_track_mode": string(psConfig.TrackMode), + "is_fork": forkConfig.IsFork, + "upstream_url": forkConfig.UpstreamURL, + "upstream_owner": forkConfig.UpstreamOwner, + "upstream_repo": forkConfig.UpstreamRepo, + "force_fork_mode": forkConfig.ForceForkMode, + }) } // handleUpdateRepoConfig updates the configuration for a repository @@ -1277,19 +1303,19 @@ func (d *Daemon) handleUpdateRepoConfig(req socket.Request) socket.Response { // Get current merge queue config currentMQConfig, err := d.state.GetMergeQueueConfig(name) if err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } // Update merge queue config with provided values mqUpdated := false - if mqEnabled, ok := req.Args["mq_enabled"].(bool); ok { + if mqEnabled, hasMqEnabled := req.Args["mq_enabled"].(bool); hasMqEnabled { currentMQConfig.Enabled = mqEnabled mqUpdated = true } - if mqTrackMode, ok := req.Args["mq_track_mode"].(string); ok { + if mqTrackMode := getOptionalStringArg(req.Args, "mq_track_mode", ""); mqTrackMode != "" { mode, err := state.ParseTrackMode(mqTrackMode) if err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } currentMQConfig.TrackMode = mode mqUpdated = true @@ -1297,7 +1323,7 @@ func (d *Daemon) handleUpdateRepoConfig(req socket.Request) socket.Response { if mqUpdated { if err := d.state.UpdateMergeQueueConfig(name, currentMQConfig); err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } d.logger.Info("Updated merge queue config for repo %s: enabled=%v, track=%s", name, currentMQConfig.Enabled, currentMQConfig.TrackMode) } @@ -1305,19 +1331,19 @@ func (d *Daemon) handleUpdateRepoConfig(req socket.Request) socket.Response { // Get current PR shepherd config currentPSConfig, err := d.state.GetPRShepherdConfig(name) if err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } // Update PR shepherd config with provided values psUpdated := false - if psEnabled, ok := req.Args["ps_enabled"].(bool); ok { + if psEnabled, hasPsEnabled := req.Args["ps_enabled"].(bool); hasPsEnabled { currentPSConfig.Enabled = psEnabled psUpdated = true } - if psTrackMode, ok := req.Args["ps_track_mode"].(string); ok { + if psTrackMode := getOptionalStringArg(req.Args, "ps_track_mode", ""); psTrackMode != "" { mode, err := state.ParseTrackMode(psTrackMode) if err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } currentPSConfig.TrackMode = mode psUpdated = true @@ -1325,12 +1351,12 @@ func (d *Daemon) handleUpdateRepoConfig(req socket.Request) socket.Response { if psUpdated { if err := d.state.UpdatePRShepherdConfig(name, currentPSConfig); err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } d.logger.Info("Updated PR shepherd config for repo %s: enabled=%v, track=%s", name, currentPSConfig.Enabled, currentPSConfig.TrackMode) } - return socket.Response{Success: true} + return socket.SuccessResponse(nil) } // handleSetCurrentRepo sets the current/default repository @@ -1341,30 +1367,30 @@ func (d *Daemon) handleSetCurrentRepo(req socket.Request) socket.Response { } if err := d.state.SetCurrentRepo(name); err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } d.logger.Info("Set current repository to: %s", name) - return socket.Response{Success: true, Data: name} + return socket.SuccessResponse(name) } // handleGetCurrentRepo returns the current/default repository func (d *Daemon) handleGetCurrentRepo(req socket.Request) socket.Response { currentRepo := d.state.GetCurrentRepo() if currentRepo == "" { - return socket.Response{Success: false, Error: "no current repository set"} + return socket.ErrorResponse("no current repository set") } - return socket.Response{Success: true, Data: currentRepo} + return socket.SuccessResponse(currentRepo) } // handleClearCurrentRepo clears the current/default repository func (d *Daemon) handleClearCurrentRepo(req socket.Request) socket.Response { if err := d.state.ClearCurrentRepo(); err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } d.logger.Info("Cleared current repository") - return socket.Response{Success: true} + return socket.SuccessResponse(nil) } // cleanupDeadAgents removes dead agents from state @@ -1475,7 +1501,7 @@ func (d *Daemon) handleTaskHistory(req socket.Request) socket.Response { history, err := d.state.GetTaskHistory(repoName, limit) if err != nil { - return socket.Response{Success: false, Error: err.Error()} + return socket.ErrorResponse("%s", err.Error()) } // Convert to interface slice for JSON serialization @@ -1495,7 +1521,7 @@ func (d *Daemon) handleTaskHistory(req socket.Request) socket.Response { } } - return socket.Response{Success: true, Data: result} + return socket.SuccessResponse(result) } // handleSpawnAgent spawns a new agent with an inline prompt (no hardcoded type). @@ -1529,24 +1555,21 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { // Validate class if agentClass != "persistent" && agentClass != "ephemeral" { - return socket.Response{ - Success: false, - Error: fmt.Sprintf("invalid agent class %q: must be 'persistent' or 'ephemeral'", agentClass), - } + return socket.ErrorResponse("invalid agent class %q: must be 'persistent' or 'ephemeral'", agentClass) } // Get optional task - task, _ := req.Args["task"].(string) + task := getOptionalStringArg(req.Args, "task", "") // Get repository repo, exists := d.state.GetRepo(repoName) if !exists { - return socket.Response{Success: false, Error: fmt.Sprintf("repository %q not found", repoName)} + return socket.ErrorResponse("repository %q not found", repoName) } // Check if agent already exists if _, exists := d.state.GetAgent(repoName, agentName); exists { - return socket.Response{Success: false, Error: fmt.Sprintf("agent %q already exists in repository %q", agentName, repoName)} + return socket.ErrorResponse("agent %q already exists in repository %q", agentName, repoName) } // Determine agent type based on class @@ -1584,7 +1607,7 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { // Ephemeral agents get their own worktree with a new branch branchName := fmt.Sprintf("work/%s", agentName) if err := wt.CreateNewBranch(worktreePath, branchName, "HEAD"); err != nil { - return socket.Response{Success: false, Error: fmt.Sprintf("failed to create worktree: %v", err)} + return socket.ErrorResponse("failed to create worktree: %v", err) } } @@ -1595,18 +1618,18 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { if agentClass != "persistent" { wt.Remove(worktreePath, true) } - return socket.Response{Success: false, Error: fmt.Sprintf("failed to create tmux window: %v", err)} + return socket.ErrorResponse("failed to create tmux window: %v", err) } // Write prompt to file promptDir := filepath.Join(d.paths.Root, "prompts") if err := os.MkdirAll(promptDir, 0755); err != nil { - return socket.Response{Success: false, Error: fmt.Sprintf("failed to create prompt directory: %v", err)} + return socket.ErrorResponse("failed to create prompt directory: %v", err) } promptPath := filepath.Join(promptDir, fmt.Sprintf("%s.md", agentName)) if err := os.WriteFile(promptPath, []byte(promptText), 0644); err != nil { - return socket.Response{Success: false, Error: fmt.Sprintf("failed to write prompt file: %v", err)} + return socket.ErrorResponse("failed to write prompt file: %v", err) } // Copy hooks config @@ -1628,7 +1651,7 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { if agentClass != "persistent" { wt.Remove(worktreePath, true) } - return socket.Response{Success: false, Error: fmt.Sprintf("failed to start agent: %v", err)} + return socket.ErrorResponse("failed to start agent: %v", err) } // Update task if provided @@ -1640,15 +1663,12 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { d.logger.Info("Spawned agent %s/%s (class=%s, type=%s)", repoName, agentName, agentClass, agentType) - return socket.Response{ - Success: true, - Data: map[string]interface{}{ - "name": agentName, - "class": agentClass, - "type": string(agentType), - "worktree_path": worktreePath, - }, - } + return socket.SuccessResponse(map[string]interface{}{ + "name": agentName, + "class": agentClass, + "type": string(agentType), + "worktree_path": worktreePath, + }) } // cleanupOrphanedWorktrees removes worktree directories without git tracking diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 503540d..a882edb 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -39,6 +39,7 @@ func setupTestDaemon(t *testing.T) (*Daemon, func()) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } // Create directories diff --git a/internal/daemon/handlers_test.go b/internal/daemon/handlers_test.go index ddbe8f8..9e66ebb 100644 --- a/internal/daemon/handlers_test.go +++ b/internal/daemon/handlers_test.go @@ -33,6 +33,7 @@ func setupTestDaemonWithState(t *testing.T, setupFn func(*state.State)) (*Daemon MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -1316,3 +1317,719 @@ func TestHandleClearCurrentRepo(t *testing.T) { t.Errorf("Current repo not cleared, got: %s", d.state.GetCurrentRepo()) } } + +// TestHandleTriggerRefresh tests the trigger_refresh handler +func TestHandleTriggerRefresh(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, nil) + defer cleanup() + + resp := d.handleTriggerRefresh(socket.Request{ + Command: "trigger_refresh", + }) + + if !resp.Success { + t.Errorf("Expected success, got error: %s", resp.Error) + } + + data, ok := resp.Data.(string) + if !ok { + t.Error("Expected string data in response") + } + if data != "Worktree refresh triggered" { + t.Errorf("Unexpected response data: %s", data) + } +} + +// TestHandleRestartAgentTableDriven tests handleRestartAgent with various scenarios +func TestHandleRestartAgentTableDriven(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + setupState func(*state.State) + wantSuccess bool + wantError string + }{ + { + name: "missing repo argument", + args: map[string]interface{}{"agent": "test"}, + wantSuccess: false, + wantError: "repo", + }, + { + name: "empty repo argument", + args: map[string]interface{}{"repo": "", "agent": "test"}, + wantSuccess: false, + wantError: "repo", + }, + { + name: "missing agent argument", + args: map[string]interface{}{"repo": "test-repo"}, + wantSuccess: false, + wantError: "agent", + }, + { + name: "empty agent argument", + args: map[string]interface{}{"repo": "test-repo", "agent": ""}, + wantSuccess: false, + wantError: "agent", + }, + { + name: "agent does not exist", + args: map[string]interface{}{ + "repo": "test-repo", + "agent": "nonexistent", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + }, + wantSuccess: false, + wantError: "not found", + }, + { + name: "repo does not exist", + args: map[string]interface{}{ + "repo": "nonexistent-repo", + "agent": "test-agent", + }, + wantSuccess: false, + wantError: "not found", + }, + { + name: "agent marked for cleanup", + args: map[string]interface{}{ + "repo": "test-repo", + "agent": "completed-agent", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + s.AddAgent("test-repo", "completed-agent", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "completed-window", + ReadyForCleanup: true, + CreatedAt: time.Now(), + }) + }, + wantSuccess: false, + wantError: "complete", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, tt.setupState) + defer cleanup() + + resp := d.handleRestartAgent(socket.Request{ + Command: "restart_agent", + Args: tt.args, + }) + + if resp.Success != tt.wantSuccess { + t.Errorf("handleRestartAgent() success = %v, want %v (error: %s)", resp.Success, tt.wantSuccess, resp.Error) + } + + if tt.wantError != "" && resp.Error == "" { + t.Errorf("handleRestartAgent() expected error containing %q, got empty error", tt.wantError) + } + }) + } +} + +// TestHandleSpawnAgentTableDriven tests handleSpawnAgent with various argument combinations +func TestHandleSpawnAgentTableDriven(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + setupState func(*state.State) + wantSuccess bool + wantError string + }{ + { + name: "missing repo argument", + args: map[string]interface{}{"name": "test", "class": "ephemeral", "prompt": "test prompt"}, + wantSuccess: false, + wantError: "repo", + }, + { + name: "empty repo argument", + args: map[string]interface{}{"repo": "", "name": "test", "class": "ephemeral", "prompt": "test prompt"}, + wantSuccess: false, + wantError: "repo", + }, + { + name: "missing name argument", + args: map[string]interface{}{"repo": "test-repo", "class": "ephemeral", "prompt": "test prompt"}, + wantSuccess: false, + wantError: "name", + }, + { + name: "empty name argument", + args: map[string]interface{}{"repo": "test-repo", "name": "", "class": "ephemeral", "prompt": "test prompt"}, + wantSuccess: false, + wantError: "name", + }, + { + name: "missing class argument", + args: map[string]interface{}{"repo": "test-repo", "name": "test", "prompt": "test prompt"}, + wantSuccess: false, + wantError: "class", + }, + { + name: "empty class argument", + args: map[string]interface{}{"repo": "test-repo", "name": "test", "class": "", "prompt": "test prompt"}, + wantSuccess: false, + wantError: "class", + }, + { + name: "missing prompt argument", + args: map[string]interface{}{"repo": "test-repo", "name": "test", "class": "ephemeral"}, + wantSuccess: false, + wantError: "prompt", + }, + { + name: "empty prompt argument", + args: map[string]interface{}{"repo": "test-repo", "name": "test", "class": "ephemeral", "prompt": ""}, + wantSuccess: false, + wantError: "prompt", + }, + { + name: "invalid class argument", + args: map[string]interface{}{ + "repo": "test-repo", + "name": "test", + "class": "invalid-class", + "prompt": "test prompt", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + }, + wantSuccess: false, + wantError: "invalid agent class", + }, + { + name: "repo does not exist", + args: map[string]interface{}{ + "repo": "nonexistent", + "name": "test", + "class": "ephemeral", + "prompt": "test prompt", + }, + wantSuccess: false, + wantError: "not found", + }, + { + name: "agent already exists", + args: map[string]interface{}{ + "repo": "test-repo", + "name": "existing-agent", + "class": "ephemeral", + "prompt": "test prompt", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + s.AddAgent("test-repo", "existing-agent", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "existing-window", + CreatedAt: time.Now(), + }) + }, + wantSuccess: false, + wantError: "already exists", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, tt.setupState) + defer cleanup() + + resp := d.handleSpawnAgent(socket.Request{ + Command: "spawn_agent", + Args: tt.args, + }) + + if resp.Success != tt.wantSuccess { + t.Errorf("handleSpawnAgent() success = %v, want %v (error: %s)", resp.Success, tt.wantSuccess, resp.Error) + } + + if tt.wantError != "" && resp.Error == "" { + t.Errorf("handleSpawnAgent() expected error containing %q, got empty error", tt.wantError) + } + }) + } +} + +// TestHandleRepairStateBasic tests the repair_state handler with basic scenarios +func TestHandleRepairStateBasic(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + }) + defer cleanup() + + resp := d.handleRepairState(socket.Request{ + Command: "repair_state", + }) + + if !resp.Success { + t.Errorf("Expected success, got error: %s", resp.Error) + } + + data, ok := resp.Data.(map[string]interface{}) + if !ok { + t.Error("Expected map data in response") + return + } + + if _, exists := data["agents_removed"]; !exists { + t.Error("Response should contain agents_removed field") + } + if _, exists := data["issues_fixed"]; !exists { + t.Error("Response should contain issues_fixed field") + } +} + +// TestHandleTaskHistoryTableDriven tests handleTaskHistory +func TestHandleTaskHistoryTableDriven(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + setupState func(*state.State) + wantSuccess bool + wantError string + }{ + { + name: "missing repo argument", + args: map[string]interface{}{}, + wantSuccess: false, + wantError: "repo", + }, + { + name: "empty repo argument", + args: map[string]interface{}{"repo": ""}, + wantSuccess: false, + wantError: "repo", + }, + { + name: "repo does not exist", + args: map[string]interface{}{ + "repo": "nonexistent", + }, + wantSuccess: false, + wantError: "not found", + }, + { + name: "success with empty history", + args: map[string]interface{}{ + "repo": "test-repo", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + }, + wantSuccess: true, + }, + { + name: "success with limit", + args: map[string]interface{}{ + "repo": "test-repo", + "limit": float64(5), + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + }, + wantSuccess: true, + }, + { + name: "success with status filter", + args: map[string]interface{}{ + "repo": "test-repo", + "status": "pending", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + }, + wantSuccess: true, + }, + { + name: "success with search", + args: map[string]interface{}{ + "repo": "test-repo", + "search": "test query", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + }, + wantSuccess: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, tt.setupState) + defer cleanup() + + resp := d.handleTaskHistory(socket.Request{ + Command: "task_history", + Args: tt.args, + }) + + if resp.Success != tt.wantSuccess { + t.Errorf("handleTaskHistory() success = %v, want %v (error: %s)", resp.Success, tt.wantSuccess, resp.Error) + } + + if tt.wantError != "" && resp.Error == "" { + t.Errorf("handleTaskHistory() expected error containing %q, got empty error", tt.wantError) + } + }) + } +} + +// TestHandleListAgentsTableDriven tests handleListAgents +func TestHandleListAgentsTableDriven(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + setupState func(*state.State) + wantSuccess bool + wantAgents int + }{ + { + name: "missing repo argument", + args: map[string]interface{}{}, + wantSuccess: false, + }, + { + name: "empty repo returns empty list", + args: map[string]interface{}{ + "repo": "test-repo", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + }, + wantSuccess: true, + wantAgents: 0, + }, + { + name: "repo with multiple agents", + args: map[string]interface{}{ + "repo": "test-repo", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + s.AddAgent("test-repo", "worker1", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "worker1-window", + CreatedAt: time.Now(), + }) + s.AddAgent("test-repo", "worker2", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "worker2-window", + CreatedAt: time.Now(), + }) + }, + wantSuccess: true, + wantAgents: 2, + }, + { + name: "returns all agents regardless of type", + args: map[string]interface{}{ + "repo": "test-repo", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + s.AddAgent("test-repo", "worker1", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "worker1-window", + CreatedAt: time.Now(), + }) + s.AddAgent("test-repo", "supervisor", state.Agent{ + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor-window", + CreatedAt: time.Now(), + }) + }, + wantSuccess: true, + wantAgents: 2, // Returns all agents + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, tt.setupState) + defer cleanup() + + resp := d.handleListAgents(socket.Request{ + Command: "list_agents", + Args: tt.args, + }) + + if resp.Success != tt.wantSuccess { + t.Errorf("handleListAgents() success = %v, want %v (error: %s)", resp.Success, tt.wantSuccess, resp.Error) + } + + if tt.wantSuccess { + agents, ok := resp.Data.([]map[string]interface{}) + if !ok { + t.Errorf("Expected []map[string]interface{} data in response, got %T", resp.Data) + return + } + if len(agents) != tt.wantAgents { + t.Errorf("Expected %d agents, got %d", tt.wantAgents, len(agents)) + } + } + }) + } +} + +// TestHandleUpdateRepoConfigTableDriven tests handleUpdateRepoConfig +func TestHandleUpdateRepoConfigTableDriven(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + setupState func(*state.State) + wantSuccess bool + wantError string + }{ + { + name: "missing name argument", + args: map[string]interface{}{}, + wantSuccess: false, + wantError: "name", + }, + { + name: "empty name argument", + args: map[string]interface{}{"name": ""}, + wantSuccess: false, + wantError: "name", + }, + { + name: "repo does not exist", + args: map[string]interface{}{ + "name": "nonexistent", + }, + wantSuccess: false, + wantError: "not found", + }, + { + name: "update merge queue enabled", + args: map[string]interface{}{ + "name": "test-repo", + "mq_enabled": false, + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + MergeQueueConfig: state.MergeQueueConfig{ + Enabled: true, + TrackMode: state.TrackModeAll, + }, + }) + }, + wantSuccess: true, + }, + { + name: "update merge queue track mode", + args: map[string]interface{}{ + "name": "test-repo", + "mq_track_mode": "author", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + MergeQueueConfig: state.MergeQueueConfig{ + Enabled: true, + TrackMode: state.TrackModeAll, + }, + }) + }, + wantSuccess: true, + }, + { + name: "update pr shepherd enabled", + args: map[string]interface{}{ + "name": "test-repo", + "ps_enabled": true, + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + PRShepherdConfig: state.PRShepherdConfig{ + Enabled: false, + TrackMode: state.TrackModeAll, + }, + }) + }, + wantSuccess: true, + }, + { + name: "invalid track mode", + args: map[string]interface{}{ + "name": "test-repo", + "mq_track_mode": "invalid", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + }) + }, + wantSuccess: false, + wantError: "invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, tt.setupState) + defer cleanup() + + resp := d.handleUpdateRepoConfig(socket.Request{ + Command: "update_repo_config", + Args: tt.args, + }) + + if resp.Success != tt.wantSuccess { + t.Errorf("handleUpdateRepoConfig() success = %v, want %v (error: %s)", resp.Success, tt.wantSuccess, resp.Error) + } + + if tt.wantError != "" && resp.Error == "" { + t.Errorf("handleUpdateRepoConfig() expected error containing %q, got empty error", tt.wantError) + } + }) + } +} + +// TestHandleGetRepoConfigTableDriven tests handleGetRepoConfig +func TestHandleGetRepoConfigTableDriven(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + setupState func(*state.State) + wantSuccess bool + wantError string + }{ + { + name: "missing name argument", + args: map[string]interface{}{}, + wantSuccess: false, + wantError: "name", + }, + { + name: "empty name argument", + args: map[string]interface{}{"name": ""}, + wantSuccess: false, + wantError: "name", + }, + { + name: "repo does not exist", + args: map[string]interface{}{ + "name": "nonexistent", + }, + wantSuccess: false, + wantError: "not found", + }, + { + name: "success", + args: map[string]interface{}{ + "name": "test-repo", + }, + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "test-session", + Agents: make(map[string]state.Agent), + MergeQueueConfig: state.MergeQueueConfig{ + Enabled: true, + TrackMode: state.TrackModeAll, + }, + }) + }, + wantSuccess: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, tt.setupState) + defer cleanup() + + resp := d.handleGetRepoConfig(socket.Request{ + Command: "get_repo_config", + Args: tt.args, + }) + + if resp.Success != tt.wantSuccess { + t.Errorf("handleGetRepoConfig() success = %v, want %v (error: %s)", resp.Success, tt.wantSuccess, resp.Error) + } + + if tt.wantError != "" && resp.Error == "" { + t.Errorf("handleGetRepoConfig() expected error containing %q, got empty error", tt.wantError) + } + + if tt.wantSuccess { + data, ok := resp.Data.(map[string]interface{}) + if !ok { + t.Error("Expected map data in response") + return + } + if _, exists := data["mq_enabled"]; !exists { + t.Error("Response should contain mq_enabled field") + } + } + }) + } +} diff --git a/internal/daemon/worktree_test.go b/internal/daemon/worktree_test.go index 1cb400c..426f885 100644 --- a/internal/daemon/worktree_test.go +++ b/internal/daemon/worktree_test.go @@ -72,6 +72,7 @@ func setupTestDaemonWithGitRepo(t *testing.T) (*Daemon, string, func()) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } // Create directories diff --git a/internal/diagnostics/collector.go b/internal/diagnostics/collector.go new file mode 100644 index 0000000..43dfcae --- /dev/null +++ b/internal/diagnostics/collector.go @@ -0,0 +1,352 @@ +package diagnostics + +import ( + "encoding/json" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + + "github.com/dlorenc/multiclaude/internal/state" + "github.com/dlorenc/multiclaude/pkg/config" +) + +// Report contains all diagnostic information in machine-readable format +type Report struct { + // Version information + Version VersionInfo `json:"version"` + Environment EnvironmentInfo `json:"environment"` + Capabilities CapabilitiesInfo `json:"capabilities"` + Tools ToolsInfo `json:"tools"` + Daemon DaemonInfo `json:"daemon"` + Statistics StatisticsInfo `json:"statistics"` +} + +// VersionInfo contains version details for multiclaude and dependencies +type VersionInfo struct { + Multiclaude string `json:"multiclaude"` + Go string `json:"go"` + IsDev bool `json:"is_dev"` +} + +// EnvironmentInfo contains environment variables and system information +type EnvironmentInfo struct { + OS string `json:"os"` + Arch string `json:"arch"` + HomeDir string `json:"home_dir"` + Paths PathsInfo `json:"paths"` + Variables map[string]string `json:"variables"` +} + +// PathsInfo contains multiclaude directory paths +type PathsInfo struct { + Root string `json:"root"` + StateFile string `json:"state_file"` + DaemonPID string `json:"daemon_pid"` + DaemonSock string `json:"daemon_sock"` + DaemonLog string `json:"daemon_log"` + ReposDir string `json:"repos_dir"` + WorktreesDir string `json:"worktrees_dir"` + OutputDir string `json:"output_dir"` + MessagesDir string `json:"messages_dir"` +} + +// CapabilitiesInfo describes what features are available +type CapabilitiesInfo struct { + TaskManagement bool `json:"task_management"` + ClaudeInstalled bool `json:"claude_installed"` + TmuxInstalled bool `json:"tmux_installed"` + GitInstalled bool `json:"git_installed"` +} + +// ToolsInfo contains version information for external tools +type ToolsInfo struct { + Claude ClaudeInfo `json:"claude"` + Tmux string `json:"tmux"` + Git string `json:"git"` +} + +// ClaudeInfo contains detailed information about the Claude CLI +type ClaudeInfo struct { + Installed bool `json:"installed"` + Version string `json:"version"` + Path string `json:"path"` +} + +// DaemonInfo contains information about the daemon process +type DaemonInfo struct { + Running bool `json:"running"` + PID int `json:"pid"` +} + +// StatisticsInfo contains agent and repository counts +type StatisticsInfo struct { + Repositories int `json:"repositories"` + Workers int `json:"workers"` + Supervisors int `json:"supervisors"` + MergeQueues int `json:"merge_queues"` + Workspaces int `json:"workspaces"` + ReviewAgents int `json:"review_agents"` +} + +// Collector gathers diagnostic information +type Collector struct { + paths *config.Paths + version string +} + +// NewCollector creates a new diagnostic collector +func NewCollector(paths *config.Paths, version string) *Collector { + return &Collector{ + paths: paths, + version: version, + } +} + +// Collect gathers all diagnostic information +func (c *Collector) Collect() (*Report, error) { + report := &Report{ + Version: VersionInfo{ + Multiclaude: c.version, + Go: runtime.Version(), + IsDev: strings.Contains(c.version, "dev") || strings.Contains(c.version, "unknown"), + }, + Environment: c.collectEnvironment(), + Tools: c.collectTools(), + Daemon: c.collectDaemon(), + Statistics: c.collectStatistics(), + } + + // Determine capabilities based on tool versions + report.Capabilities = c.determineCapabilities(report.Tools) + + return report, nil +} + +// collectEnvironment gathers environment information +func (c *Collector) collectEnvironment() EnvironmentInfo { + homeDir, _ := os.UserHomeDir() + + // Collect important environment variables + envVars := make(map[string]string) + importantVars := []string{ + "MULTICLAUDE_TEST_MODE", + "CLAUDE_CONFIG_DIR", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_PROJECT_DIR", + "PATH", + "SHELL", + "TERM", + "TMUX", + } + + for _, varName := range importantVars { + if value := os.Getenv(varName); value != "" { + // Redact sensitive values + if strings.Contains(strings.ToLower(varName), "token") || + strings.Contains(strings.ToLower(varName), "key") { + envVars[varName] = "[REDACTED]" + } else { + envVars[varName] = value + } + } + } + + return EnvironmentInfo{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + HomeDir: homeDir, + Paths: PathsInfo{ + Root: c.paths.Root, + StateFile: c.paths.StateFile, + DaemonPID: c.paths.DaemonPID, + DaemonSock: c.paths.DaemonSock, + DaemonLog: c.paths.DaemonLog, + ReposDir: c.paths.ReposDir, + WorktreesDir: c.paths.WorktreesDir, + OutputDir: c.paths.OutputDir, + MessagesDir: c.paths.MessagesDir, + }, + Variables: envVars, + } +} + +// collectTools gathers information about external tools +func (c *Collector) collectTools() ToolsInfo { + return ToolsInfo{ + Claude: c.getClaudeInfo(), + Tmux: c.getToolVersion("tmux", "-V"), + Git: c.getToolVersion("git", "--version"), + } +} + +// getClaudeInfo returns detailed information about Claude CLI +func (c *Collector) getClaudeInfo() ClaudeInfo { + path, err := exec.LookPath("claude") + if err != nil { + return ClaudeInfo{ + Installed: false, + } + } + + cmd := exec.Command("claude", "--version") + output, err := cmd.Output() + if err != nil { + return ClaudeInfo{ + Installed: true, + Path: path, + Version: "unknown", + } + } + + version := strings.TrimSpace(string(output)) + return ClaudeInfo{ + Installed: true, + Path: path, + Version: version, + } +} + +// getToolVersion returns the version string for a tool +func (c *Collector) getToolVersion(tool string, versionFlag string) string { + cmd := exec.Command(tool, versionFlag) + output, err := cmd.Output() + if err != nil { + return "not installed" + } + return strings.TrimSpace(string(output)) +} + +// determineCapabilities determines what features are available +func (c *Collector) determineCapabilities(tools ToolsInfo) CapabilitiesInfo { + capabilities := CapabilitiesInfo{ + ClaudeInstalled: tools.Claude.Installed, + TmuxInstalled: tools.Tmux != "not installed", + GitInstalled: tools.Git != "not installed", + } + + // Task management is available in Claude Code 2.0+ + if tools.Claude.Installed && tools.Claude.Version != "unknown" { + capabilities.TaskManagement = c.detectTaskManagementSupport(tools.Claude.Version) + } + + return capabilities +} + +// detectTaskManagementSupport checks if the Claude version supports task management +func (c *Collector) detectTaskManagementSupport(version string) bool { + // Task management (TaskCreate/Update/List/Get) was introduced in Claude Code 2.0 + // Version format: "X.Y.Z (Claude Code)" or just "X.Y.Z" + + // Extract version number from string like "2.1.17 (Claude Code)" + parts := strings.Fields(version) + if len(parts) == 0 { + return false + } + + versionNum := parts[0] + versionParts := strings.Split(versionNum, ".") + if len(versionParts) < 2 { + return false + } + + major, err := strconv.Atoi(versionParts[0]) + if err != nil { + return false + } + + // Task management available in v2.0+ + return major >= 2 +} + +// collectDaemon gathers daemon status information +func (c *Collector) collectDaemon() DaemonInfo { + pidData, err := os.ReadFile(c.paths.DaemonPID) + if err != nil { + return DaemonInfo{ + Running: false, + PID: 0, + } + } + + pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))) + if err != nil { + return DaemonInfo{ + Running: false, + PID: 0, + } + } + + // Check if process is running + process, err := os.FindProcess(pid) + if err != nil { + return DaemonInfo{ + Running: false, + PID: pid, + } + } + + // On Unix, FindProcess always succeeds, so we send signal 0 to check + err = process.Signal(os.Signal(nil)) + if err != nil { + return DaemonInfo{ + Running: false, + PID: pid, + } + } + + return DaemonInfo{ + Running: true, + PID: pid, + } +} + +// collectStatistics gathers agent and repository statistics +func (c *Collector) collectStatistics() StatisticsInfo { + st, err := state.Load(c.paths.StateFile) + if err != nil { + return StatisticsInfo{} + } + + stats := StatisticsInfo{} + repos := st.GetAllRepos() + stats.Repositories = len(repos) + + for _, repo := range repos { + for _, agent := range repo.Agents { + switch agent.Type { + case state.AgentTypeWorker: + stats.Workers++ + case state.AgentTypeSupervisor: + stats.Supervisors++ + case state.AgentTypeMergeQueue: + stats.MergeQueues++ + case state.AgentTypeWorkspace: + stats.Workspaces++ + case state.AgentTypeReview: + stats.ReviewAgents++ + } + } + } + + return stats +} + +// ToJSON converts the report to JSON format +func (r *Report) ToJSON(pretty bool) (string, error) { + var data []byte + var err error + + if pretty { + data, err = json.MarshalIndent(r, "", " ") + } else { + data, err = json.Marshal(r) + } + + if err != nil { + return "", err + } + + return string(data), nil +} diff --git a/internal/fork/api_test.go b/internal/fork/api_test.go index 258c2eb..b14ce77 100644 --- a/internal/fork/api_test.go +++ b/internal/fork/api_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -151,17 +152,15 @@ func TestDetectFork_ForkWithExistingUpstream(t *testing.T) { tmpDir := setupTestRepo(t) defer os.RemoveAll(tmpDir) - // Add origin - cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/myuser/myrepo") - cmd.Dir = tmpDir + // Add origin (using isolated git to avoid URL rewrites) + cmd := gitCmdIsolated(tmpDir, "remote", "add", "origin", "https://github.com/myuser/myrepo") if err := cmd.Run(); err != nil { t.Fatalf("failed to add origin: %v", err) } // Add upstream (simulating a fork) upstreamURL := "https://github.com/upstream/repo" - cmd = exec.Command("git", "remote", "add", "upstream", upstreamURL) - cmd.Dir = tmpDir + cmd = gitCmdIsolated(tmpDir, "remote", "add", "upstream", upstreamURL) if err := cmd.Run(); err != nil { t.Fatalf("failed to add upstream: %v", err) } @@ -175,8 +174,9 @@ func TestDetectFork_ForkWithExistingUpstream(t *testing.T) { if !info.IsFork { t.Error("expected IsFork to be true with upstream remote") } - if info.UpstreamURL != upstreamURL { - t.Errorf("UpstreamURL = %q, want %q", info.UpstreamURL, upstreamURL) + // Use urlsEquivalent for comparison since user config may rewrite URLs + if !urlsEquivalent(info.UpstreamURL, upstreamURL) { + t.Errorf("UpstreamURL = %q, want equivalent to %q", info.UpstreamURL, upstreamURL) } } @@ -185,16 +185,14 @@ func TestDetectFork_SSHRemotes(t *testing.T) { tmpDir := setupTestRepo(t) defer os.RemoveAll(tmpDir) - // Add origin with SSH URL - cmd := exec.Command("git", "remote", "add", "origin", "git@github.com:myuser/myrepo.git") - cmd.Dir = tmpDir + // Add origin with SSH URL (using isolated git to prevent URL rewrites) + cmd := gitCmdIsolated(tmpDir, "remote", "add", "origin", "git@github.com:myuser/myrepo.git") if err := cmd.Run(); err != nil { t.Fatalf("failed to add origin: %v", err) } // Add upstream with SSH URL - cmd = exec.Command("git", "remote", "add", "upstream", "git@github.com:upstream/repo.git") - cmd.Dir = tmpDir + cmd = gitCmdIsolated(tmpDir, "remote", "add", "upstream", "git@github.com:upstream/repo.git") if err := cmd.Run(); err != nil { t.Fatalf("failed to add upstream: %v", err) } @@ -233,16 +231,15 @@ func TestAddUpstreamRemote_Idempotent(t *testing.T) { t.Fatalf("Second AddUpstreamRemote() failed: %v", err) } - // Verify URL is correct - cmd := exec.Command("git", "remote", "get-url", "upstream") - cmd.Dir = tmpDir + // Verify URL is correct - use urlsEquivalent for comparison since user config may rewrite URLs + cmd := exec.Command("git", "-C", tmpDir, "remote", "get-url", "upstream") output, err := cmd.Output() if err != nil { t.Fatalf("failed to get upstream url: %v", err) } - got := string(output) - if got != upstreamURL+"\n" { - t.Errorf("upstream URL = %q, want %q", got, upstreamURL) + got := strings.TrimSpace(string(output)) + if !urlsEquivalent(got, upstreamURL) { + t.Errorf("upstream URL = %q, want equivalent to %q", got, upstreamURL) } } @@ -305,11 +302,13 @@ func TestParseGitHubURL_EdgeCases(t *testing.T) { wantRepo: "repo", wantErr: false, }, - // The current regex doesn't match dots in repo names + // Dots in repo names are now supported { - name: "dots in repo name - current impl returns error", - url: "https://github.com/owner/my.dotted.repo", - wantErr: true, + name: "dots in repo name", + url: "https://github.com/owner/my.dotted.repo", + wantOwner: "owner", + wantRepo: "my.dotted.repo", + wantErr: false, }, } @@ -337,7 +336,7 @@ func TestGetRemoteURL_MultipleRemotes(t *testing.T) { tmpDir := setupTestRepo(t) defer os.RemoveAll(tmpDir) - // Add multiple remotes + // Add multiple remotes (using isolated git to prevent URL rewrites) remotes := map[string]string{ "origin": "https://github.com/test/origin-repo", "upstream": "https://github.com/test/upstream-repo", @@ -345,22 +344,22 @@ func TestGetRemoteURL_MultipleRemotes(t *testing.T) { } for name, url := range remotes { - cmd := exec.Command("git", "remote", "add", name, url) - cmd.Dir = tmpDir + cmd := gitCmdIsolated(tmpDir, "remote", "add", name, url) if err := cmd.Run(); err != nil { t.Fatalf("failed to add remote %s: %v", name, err) } } - // Test getting each remote URL + // Test getting each remote URL - use urlsEquivalent for comparison + // since user config may rewrite URLs for name, expectedURL := range remotes { url, err := getRemoteURL(tmpDir, name) if err != nil { t.Errorf("getRemoteURL(%s) failed: %v", name, err) continue } - if url != expectedURL { - t.Errorf("getRemoteURL(%s) = %q, want %q", name, url, expectedURL) + if !urlsEquivalent(url, expectedURL) { + t.Errorf("getRemoteURL(%s) = %q, want equivalent to %q", name, url, expectedURL) } } } @@ -371,9 +370,8 @@ func TestDetectFork_SymlinkPath(t *testing.T) { tmpDir := setupTestRepo(t) defer os.RemoveAll(tmpDir) - // Add origin - cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/myuser/myrepo") - cmd.Dir = tmpDir + // Add origin (using isolated git to prevent URL rewrites) + cmd := gitCmdIsolated(tmpDir, "remote", "add", "origin", "https://github.com/myuser/myrepo") if err := cmd.Run(); err != nil { t.Fatalf("failed to add origin: %v", err) } diff --git a/internal/fork/fork.go b/internal/fork/fork.go index 350da0e..6b4f367 100644 --- a/internal/fork/fork.go +++ b/internal/fork/fork.go @@ -104,13 +104,15 @@ func getRemoteURL(repoPath, remoteName string) (string, error) { // - git@github.com:owner/repo func ParseGitHubURL(url string) (owner, repo string, err error) { // HTTPS format: https://github.com/owner/repo(.git)? - httpsRegex := regexp.MustCompile(`^https://github\.com/([^/]+)/([^/.]+)(?:\.git)?$`) + // Note: repo name can contain dots (e.g., demos.expanso.io) + httpsRegex := regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+?)(?:\.git)?$`) if matches := httpsRegex.FindStringSubmatch(url); matches != nil { return matches[1], matches[2], nil } // SSH format: git@github.com:owner/repo(.git)? - sshRegex := regexp.MustCompile(`^git@github\.com:([^/]+)/([^/.]+)(?:\.git)?$`) + // Note: repo name can contain dots (e.g., demos.expanso.io) + sshRegex := regexp.MustCompile(`^git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$`) if matches := sshRegex.FindStringSubmatch(url); matches != nil { return matches[1], matches[2], nil } diff --git a/internal/fork/fork_test.go b/internal/fork/fork_test.go index d09b619..27e66ff 100644 --- a/internal/fork/fork_test.go +++ b/internal/fork/fork_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -116,7 +117,40 @@ func TestForkInfo(t *testing.T) { } } +// gitCmdIsolated creates an exec.Cmd for git that is isolated from global configuration. +// This is important for tests that need deterministic behavior regardless of user's +// global git settings (e.g., url.insteadOf rewrites). +func gitCmdIsolated(dir string, args ...string) *exec.Cmd { + cmd := exec.Command("git", args...) + cmd.Dir = dir + // Isolate from global and system git config by pointing to /dev/null + // This prevents url.insteadOf and other global settings from affecting tests + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ) + return cmd +} + +// urlsEquivalent compares two GitHub URLs for equivalence, treating HTTPS and SSH +// formats as equal if they refer to the same owner/repo. This handles cases where +// users have url.insteadOf configured globally which rewrites URLs. +// Returns true if both URLs resolve to the same owner/repo. +func urlsEquivalent(url1, url2 string) bool { + owner1, repo1, err1 := ParseGitHubURL(url1) + owner2, repo2, err2 := ParseGitHubURL(url2) + + if err1 != nil || err2 != nil { + // If we can't parse, fall back to exact comparison + return url1 == url2 + } + + return owner1 == owner2 && repo1 == repo2 +} + // setupTestRepo creates a temporary git repository for testing. +// It isolates the repo from global git configuration to ensure consistent behavior +// regardless of user's git settings (e.g., url.insteadOf rewrites). func setupTestRepo(t *testing.T) string { t.Helper() tmpDir, err := os.MkdirTemp("", "fork-test-*") @@ -124,20 +158,17 @@ func setupTestRepo(t *testing.T) string { t.Fatalf("failed to create temp dir: %v", err) } - // Initialize git repo - cmd := exec.Command("git", "init") - cmd.Dir = tmpDir + // Initialize git repo with isolated config + cmd := gitCmdIsolated(tmpDir, "init") if err := cmd.Run(); err != nil { os.RemoveAll(tmpDir) t.Fatalf("failed to init git repo: %v", err) } // Configure git user for commits - cmd = exec.Command("git", "config", "user.email", "test@example.com") - cmd.Dir = tmpDir + cmd = gitCmdIsolated(tmpDir, "config", "user.email", "test@example.com") cmd.Run() - cmd = exec.Command("git", "config", "user.name", "Test User") - cmd.Dir = tmpDir + cmd = gitCmdIsolated(tmpDir, "config", "user.name", "Test User") cmd.Run() return tmpDir @@ -152,9 +183,8 @@ func TestHasUpstreamRemote(t *testing.T) { t.Error("expected no upstream remote initially") } - // Add upstream remote - cmd := exec.Command("git", "remote", "add", "upstream", "https://github.com/upstream/repo") - cmd.Dir = tmpDir + // Add upstream remote (using isolated git to avoid URL rewrites) + cmd := gitCmdIsolated(tmpDir, "remote", "add", "upstream", "https://github.com/upstream/repo") if err := cmd.Run(); err != nil { t.Fatalf("failed to add upstream: %v", err) } @@ -181,16 +211,16 @@ func TestAddUpstreamRemote(t *testing.T) { t.Error("upstream remote not added") } - // Verify URL - cmd := exec.Command("git", "remote", "get-url", "upstream") - cmd.Dir = tmpDir + // Verify URL - use urlsEquivalent because user's git config may rewrite URLs + // (e.g., url.git@github.com:.insteadof=https://github.com/) + cmd := exec.Command("git", "-C", tmpDir, "remote", "get-url", "upstream") output, err := cmd.Output() if err != nil { t.Fatalf("failed to get upstream url: %v", err) } - got := string(output) - if got != upstreamURL+"\n" { - t.Errorf("upstream URL = %q, want %q", got, upstreamURL) + got := strings.TrimSpace(string(output)) + if !urlsEquivalent(got, upstreamURL) { + t.Errorf("upstream URL = %q, want equivalent to %q", got, upstreamURL) } // Update existing upstream @@ -199,15 +229,14 @@ func TestAddUpstreamRemote(t *testing.T) { t.Fatalf("AddUpstreamRemote() update failed: %v", err) } - cmd = exec.Command("git", "remote", "get-url", "upstream") - cmd.Dir = tmpDir + cmd = exec.Command("git", "-C", tmpDir, "remote", "get-url", "upstream") output, err = cmd.Output() if err != nil { t.Fatalf("failed to get upstream url after update: %v", err) } - got = string(output) - if got != newURL+"\n" { - t.Errorf("upstream URL after update = %q, want %q", got, newURL) + got = strings.TrimSpace(string(output)) + if !urlsEquivalent(got, newURL) { + t.Errorf("upstream URL after update = %q, want equivalent to %q", got, newURL) } } @@ -226,9 +255,8 @@ func TestDetectFork_WithOrigin(t *testing.T) { tmpDir := setupTestRepo(t) defer os.RemoveAll(tmpDir) - // Add origin - cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/myuser/myrepo") - cmd.Dir = tmpDir + // Add origin (using isolated git to prevent URL rewrites) + cmd := gitCmdIsolated(tmpDir, "remote", "add", "origin", "https://github.com/myuser/myrepo") if err := cmd.Run(); err != nil { t.Fatalf("failed to add origin: %v", err) } @@ -251,16 +279,14 @@ func TestDetectFork_WithUpstream(t *testing.T) { tmpDir := setupTestRepo(t) defer os.RemoveAll(tmpDir) - // Add origin - cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/myuser/myrepo") - cmd.Dir = tmpDir + // Add origin (using isolated git to prevent URL rewrites) + cmd := gitCmdIsolated(tmpDir, "remote", "add", "origin", "https://github.com/myuser/myrepo") if err := cmd.Run(); err != nil { t.Fatalf("failed to add origin: %v", err) } // Add upstream (simulating a fork) - cmd = exec.Command("git", "remote", "add", "upstream", "https://github.com/original/repo") - cmd.Dir = tmpDir + cmd = gitCmdIsolated(tmpDir, "remote", "add", "upstream", "https://github.com/original/repo") if err := cmd.Run(); err != nil { t.Fatalf("failed to add upstream: %v", err) } @@ -292,20 +318,20 @@ func TestGetRemoteURL(t *testing.T) { t.Error("expected error for non-existent remote") } - // Add origin - cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test/repo") - cmd.Dir = tmpDir + // Add origin (using isolated git to avoid URL rewrites when adding) + cmd := gitCmdIsolated(tmpDir, "remote", "add", "origin", "https://github.com/test/repo") if err := cmd.Run(); err != nil { t.Fatalf("failed to add origin: %v", err) } - // Now should work + // Now should work - use urlsEquivalent for comparison since user config may rewrite URLs url, err := getRemoteURL(tmpDir, "origin") if err != nil { t.Fatalf("getRemoteURL() failed: %v", err) } - if url != "https://github.com/test/repo" { - t.Errorf("url = %q, want %q", url, "https://github.com/test/repo") + expectedURL := "https://github.com/test/repo" + if !urlsEquivalent(url, expectedURL) { + t.Errorf("url = %q, want equivalent to %q", url, expectedURL) } } diff --git a/internal/names/names.go b/internal/names/names.go index f7ef56f..c12e8bc 100644 --- a/internal/names/names.go +++ b/internal/names/names.go @@ -2,6 +2,8 @@ package names import ( "math/rand" + "regexp" + "strings" "time" ) @@ -20,7 +22,26 @@ var ( "deer", "rabbit", "squirrel", "badger", "raccoon", } + // Stop words to filter out when extracting task names + stopWords = map[string]bool{ + "a": true, "an": true, "the": true, "is": true, "are": true, "am": true, + "was": true, "were": true, "be": true, "been": true, "being": true, + "have": true, "has": true, "had": true, "do": true, "does": true, "did": true, + "will": true, "would": true, "should": true, "could": true, "may": true, + "might": true, "must": true, "can": true, "to": true, "for": true, "of": true, + "in": true, "on": true, "at": true, "by": true, "with": true, "from": true, + "as": true, "into": true, "through": true, "this": true, "that": true, + "these": true, "those": true, "it": true, "its": true, "they": true, + "their": true, "there": true, "here": true, "and": true, "or": true, + "but": true, "if": true, "because": true, "when": true, "where": true, + "how": true, "what": true, "which": true, "who": true, "why": true, + } + rng *rand.Rand + + // Regex for sanitizing names + invalidCharsRegex = regexp.MustCompile(`[^a-z0-9-]+`) + multipleHyphensRegex = regexp.MustCompile(`-+`) ) func init() { @@ -33,3 +54,162 @@ func Generate() string { animal := animals[rng.Intn(len(animals))] return adj + "-" + animal } + +// FromTask generates a descriptive worker name from a task description. +// It extracts 3-4 meaningful keywords, sanitizes them to lowercase-hyphenated format, +// and falls back to Generate() if extraction fails. +func FromTask(task string) string { + // Extract keywords + keywords := extractKeywords(task) + if len(keywords) == 0 { + return Generate() + } + + // Limit to 3-4 words + if len(keywords) > 4 { + keywords = keywords[:4] + } + + // Join and sanitize + name := strings.Join(keywords, "-") + name = sanitizeName(name) + + // Validate + if !isValidName(name) { + return Generate() + } + + return name +} + +// extractKeywords extracts meaningful keywords from a task description +func extractKeywords(task string) []string { + // Normalize to lowercase + task = strings.ToLower(task) + + // Split into words + words := strings.Fields(task) + + var keywords []string + for _, word := range words { + // Remove punctuation from word boundaries + word = strings.Trim(word, ".,!?;:\"'`()[]{}/<>") + + // Skip if empty after trimming + if word == "" { + continue + } + + // Skip stop words + if stopWords[word] { + continue + } + + // Skip very short words (likely not meaningful) + if len(word) < 2 { + continue + } + + keywords = append(keywords, word) + } + + return keywords +} + +// sanitizeName converts a name to a valid worker name format +func sanitizeName(name string) string { + // Convert to lowercase + name = strings.ToLower(name) + + // Replace invalid characters with hyphens + name = invalidCharsRegex.ReplaceAllString(name, "-") + + // Collapse multiple hyphens + name = multipleHyphensRegex.ReplaceAllString(name, "-") + + // Trim hyphens from edges + name = strings.Trim(name, "-") + + // Truncate if too long + const maxLength = 50 + if len(name) > maxLength { + name = name[:maxLength] + // Ensure we don't end with a hyphen after truncation + name = strings.TrimRight(name, "-") + } + + return name +} + +// isValidName checks if a generated name meets validation criteria +func isValidName(name string) bool { + // Must be between 3 and 50 characters + if len(name) < 3 || len(name) > 50 { + return false + } + + // Must contain at least one alphabetic character + hasAlpha := false + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + hasAlpha = true + break + } + } + if !hasAlpha { + return false + } + + // Must not start or end with hyphen + if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") { + return false + } + + // Must only contain valid characters + for _, r := range name { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } + } + + return true +} + +// EnsureUnique adds a numeric suffix to the name if it already exists in the given list +func EnsureUnique(name string, existingNames []string) string { + // Create a map for O(1) lookup + exists := make(map[string]bool) + for _, n := range existingNames { + exists[n] = true + } + + // If name is unique, return as-is + if !exists[name] { + return name + } + + // Try numeric suffixes + for i := 2; i < 1000; i++ { + candidate := name + "-" + intToString(i) + if !exists[candidate] { + return candidate + } + } + + // Fallback to random name if we somehow exhaust numeric suffixes + return Generate() +} + +// intToString converts an integer to a string without importing fmt or strconv +func intToString(n int) string { + if n == 0 { + return "0" + } + + var digits []byte + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} diff --git a/internal/names/names_test.go b/internal/names/names_test.go index 0561b63..7b1040a 100644 --- a/internal/names/names_test.go +++ b/internal/names/names_test.go @@ -148,3 +148,307 @@ func TestGenerateUniqueness(t *testing.T) { t.Errorf("Generate() shows poor distribution: one name appeared %d times in %d iterations", maxCount, iterations) } } + +// Tests for task-based naming + +func TestFromTask(t *testing.T) { + tests := []struct { + name string + task string + expected string + }{ + { + name: "basic task with meaningful words", + task: "Fix the session ID bug in authentication", + expected: "fix-session-id-bug", + }, + { + name: "task with action verb", + task: "Add user profile editing feature", + expected: "add-user-profile-editing", + }, + { + name: "task with technical terms", + task: "Refactor the database connection logic", + expected: "refactor-database-connection-logic", + }, + { + name: "simple task", + task: "Update README documentation", + expected: "update-readme-documentation", + }, + { + name: "task with acronym", + task: "Implement OAuth2 login flow", + expected: "implement-oauth2-login-flow", + }, + { + name: "task with punctuation", + task: "Fix bug: session expires too quickly!", + expected: "fix-bug-session-expires", + }, + { + name: "task with special characters", + task: "Update API (v2) endpoint configuration", + expected: "update-api-v2-endpoint", + }, + { + name: "task with multiple spaces", + task: "Fix the spacing issue", + expected: "fix-spacing-issue", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FromTask(tt.task) + if result != tt.expected { + t.Errorf("FromTask(%q) = %q, expected %q", tt.task, result, tt.expected) + } + }) + } +} + +func TestFromTaskFallback(t *testing.T) { + tests := []struct { + name string + task string + }{ + {"empty string", ""}, + {"only stop words", "the a an is are to for"}, + {"only punctuation", "!@#$%^&*()"}, + {"only spaces", " "}, + {"very short", "ab"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FromTask(tt.task) + // Should fall back to random name (adjective-animal format) + parts := strings.Split(result, "-") + if len(parts) != 2 { + t.Errorf("FromTask(%q) = %q, expected fallback to adjective-animal format", tt.task, result) + } + }) + } +} + +func TestFromTaskTruncation(t *testing.T) { + // Very long task should be truncated to max 50 characters + longTask := "Fix the extremely long and verbose task description that goes on and on with many words" + result := FromTask(longTask) + + if len(result) > 50 { + t.Errorf("FromTask() produced name longer than 50 chars: %q (%d chars)", result, len(result)) + } + + // Should still be valid + if !isValidName(result) { + t.Errorf("FromTask() produced invalid name after truncation: %q", result) + } +} + +func TestExtractKeywords(t *testing.T) { + tests := []struct { + name string + task string + expected []string + }{ + { + name: "basic extraction", + task: "Fix the bug in the system", + expected: []string{"fix", "bug", "system"}, + }, + { + name: "filters stop words", + task: "The user can login to the system", + expected: []string{"user", "login", "system"}, + }, + { + name: "handles punctuation", + task: "Fix bug: system crashes!", + expected: []string{"fix", "bug", "system", "crashes"}, + }, + { + name: "empty string", + task: "", + expected: []string{}, + }, + { + name: "only stop words", + task: "the a an is", + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractKeywords(tt.task) + if len(result) != len(tt.expected) { + t.Errorf("extractKeywords(%q) = %v, expected %v", tt.task, result, tt.expected) + return + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("extractKeywords(%q) = %v, expected %v", tt.task, result, tt.expected) + return + } + } + }) + } +} + +func TestSanitizeName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "basic sanitization", + input: "Fix Bug System", + expected: "fix-bug-system", + }, + { + name: "removes special chars", + input: "fix@bug#system", + expected: "fix-bug-system", + }, + { + name: "collapses multiple hyphens", + input: "fix---bug---system", + expected: "fix-bug-system", + }, + { + name: "trims edge hyphens", + input: "-fix-bug-system-", + expected: "fix-bug-system", + }, + { + name: "handles mixed case", + input: "FixBugSystem", + expected: "fixbugsystem", + }, + { + name: "truncates long names", + input: "this-is-a-very-long-name-that-exceeds-the-maximum-length-limit-for-worker-names", + expected: "this-is-a-very-long-name-that-exceeds-the-maximum", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeName(tt.input) + if result != tt.expected { + t.Errorf("sanitizeName(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestIsValidName(t *testing.T) { + tests := []struct { + name string + input string + valid bool + }{ + {"valid name", "fix-bug-system", true}, + {"valid short name", "fix", true}, + {"valid with numbers", "fix-bug-v2", true}, + {"too short", "ab", false}, + {"too long", strings.Repeat("a", 51), false}, + {"starts with hyphen", "-fix-bug", false}, + {"ends with hyphen", "fix-bug-", false}, + {"no alphabetic chars", "123-456", false}, + {"has uppercase", "Fix-Bug", false}, + {"has special chars", "fix_bug", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidName(tt.input) + if result != tt.valid { + t.Errorf("isValidName(%q) = %v, expected %v", tt.input, result, tt.valid) + } + }) + } +} + +func TestEnsureUnique(t *testing.T) { + existing := []string{"fix-bug", "add-feature", "update-docs"} + + tests := []struct { + name string + input string + expected string + }{ + { + name: "unique name", + input: "new-feature", + expected: "new-feature", + }, + { + name: "duplicate gets suffix", + input: "fix-bug", + expected: "fix-bug-2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := EnsureUnique(tt.input, existing) + if result != tt.expected { + t.Errorf("EnsureUnique(%q, existing) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestEnsureUniqueMultipleDuplicates(t *testing.T) { + // Test that multiple duplicates get incrementing suffixes + existing := []string{"fix-bug", "fix-bug-2", "fix-bug-3"} + + result := EnsureUnique("fix-bug", existing) + expected := "fix-bug-4" + + if result != expected { + t.Errorf("EnsureUnique(fix-bug) = %q, expected %q", result, expected) + } +} + +func TestIntToString(t *testing.T) { + tests := []struct { + input int + expected string + }{ + {0, "0"}, + {1, "1"}, + {9, "9"}, + {10, "10"}, + {99, "99"}, + {100, "100"}, + {999, "999"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := intToString(tt.input) + if result != tt.expected { + t.Errorf("intToString(%d) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestFromTaskWordLimit(t *testing.T) { + // Task with more than 4 keywords should be limited to 4 + task := "Fix the critical bug in user authentication system database connection" + result := FromTask(task) + + // Count words in result + words := strings.Split(result, "-") + if len(words) > 4 { + t.Errorf("FromTask() produced more than 4 words: %q (%d words)", result, len(words)) + } +} diff --git a/internal/prompts/supervisor.md b/internal/prompts/supervisor.md index 1c4d5c2..8436786 100644 --- a/internal/prompts/supervisor.md +++ b/internal/prompts/supervisor.md @@ -64,3 +64,14 @@ Multiple agents = chaos. That's fine. - Failed attempts eliminate paths, not waste effort - Two agents on same thing? Whichever passes CI first wins - Your job: maximize throughput of forward progress, not agent efficiency + +## Task Management (Optional) + +Use TaskCreate/TaskUpdate/TaskList/TaskGet to track multi-agent work: +- Create high-level tasks for major features +- Track which worker handles what +- Update as workers complete + +**Remember:** Tasks are for YOUR tracking, not for delaying PRs. Workers should still create PRs aggressively. + +See `docs/TASK_MANAGEMENT.md` for details. diff --git a/internal/socket/socket.go b/internal/socket/socket.go index 7ff05e9..b6e51fa 100644 --- a/internal/socket/socket.go +++ b/internal/socket/socket.go @@ -21,6 +21,23 @@ type Response struct { Error string `json:"error,omitempty"` } +// ErrorResponse creates a failure response with the given error message. +// It supports printf-style formatting. +func ErrorResponse(format string, args ...interface{}) Response { + return Response{ + Success: false, + Error: fmt.Sprintf(format, args...), + } +} + +// SuccessResponse creates a successful response with optional data. +func SuccessResponse(data interface{}) Response { + return Response{ + Success: true, + Data: data, + } +} + // Client connects to the daemon via Unix socket type Client struct { socketPath string diff --git a/internal/templates/agent-templates/worker.md b/internal/templates/agent-templates/worker.md index 7d9434d..4295bfd 100644 --- a/internal/templates/agent-templates/worker.md +++ b/internal/templates/agent-templates/worker.md @@ -31,3 +31,56 @@ multiclaude message send supervisor "Need help: [your question]" Your branch: `work/` Push to it, create PR from it. + +## Environment Hygiene + +Keep your environment clean: + +```bash +# Prefix sensitive commands with space to avoid history + export SECRET=xxx + +# Before completion, verify no credentials leaked +git diff --staged | grep -i "secret\|token\|key" +rm -f /tmp/multiclaude-* +``` + +## Feature Integration Tasks + +When integrating functionality from another PR: + +1. **Reuse First** - Search for existing code before writing new + ```bash + grep -r "functionName" internal/ pkg/ + ``` + +2. **Minimalist Extensions** - Add minimum necessary, avoid bloat + +3. **Analyze the Source PR** + ```bash + gh pr view --repo / + gh pr diff --repo / + ``` + +4. **Integration Checklist** + - Tests pass + - Code formatted + - Changes minimal and focused + - Source PR referenced in description + +## Task Management (Optional) + +Use TaskCreate/TaskUpdate for **complex multi-step work** (3+ steps): + +```bash +TaskCreate({ subject: "Fix auth bug", description: "Check middleware, tokens, tests", activeForm: "Fixing auth" }) +TaskUpdate({ taskId: "1", status: "in_progress" }) +# ... work ... +TaskUpdate({ taskId: "1", status: "completed" }) +``` + +**Skip for:** Simple fixes, single-file changes, trivial operations. + +**Important:** Tasks track work internally - still create PRs immediately when each piece is done. Don't wait for all tasks to complete. + +See `docs/TASK_MANAGEMENT.md` for details. diff --git a/openspec/changes/add-repo-lifecycle/design.md b/openspec/changes/add-repo-lifecycle/design.md new file mode 100644 index 0000000..ac4c102 --- /dev/null +++ b/openspec/changes/add-repo-lifecycle/design.md @@ -0,0 +1,166 @@ +# Design: Repo Lifecycle Management + +## Context + +Users interact with multiclaude through fragmented commands that don't provide a cohesive "session" experience. Starting work on a repo requires multiple commands, and there's no way to pause/resume work or get comprehensive status. + +**Stakeholders**: CLI users, automation scripts, external tools (TUI, web dashboard) + +**Constraints**: +- Must be backward compatible (existing commands unchanged) +- State changes must be atomic (crash-safe) +- Output formats must be consistent across commands + +## Goals / Non-Goals + +### Goals +- Unified repo lifecycle: start → work → hibernate → wake → clean +- Comprehensive status in single command +- Machine-readable output for tooling integration +- Interactive TUI for power users +- WebSocket streaming for external dashboards + +### Non-Goals +- Web UI (separate project: multiclaude-ui) +- Multi-machine coordination +- Cloud sync of hibernation state + +## Decisions + +### Decision 1: Hibernation vs Stop + +**What**: Hibernate preserves agent configuration for later resume. Stop terminates completely. + +**Why**: Users often context-switch between repos. Hibernate allows quick resume without re-specifying agent configuration. + +**Alternatives considered**: +- Just use stop/start: Loses agent configuration and task context +- Auto-save always: Adds complexity, may save unwanted state + +### Decision 2: Output Format Architecture + +**What**: All commands use `OutputFormatter` interface with implementations for text/json/yaml. + +```go +type OutputFormatter interface { + FormatStatus(status *RepoStatus) ([]byte, error) + FormatList(repos []RepoInfo) ([]byte, error) + FormatResult(result *CommandResult) ([]byte, error) +} +``` + +**Why**: Consistent formatting, easy to add new formats, testable. + +**Alternatives considered**: +- Per-command formatting: Leads to inconsistency +- Template-based: Harder to maintain, less flexible + +### Decision 3: TUI Library + +**What**: Use [bubbletea](https://github.com/charmbracelet/bubbletea) for TUI. + +**Why**: +- Popular in Go ecosystem (more LLM training data) +- Elm architecture is simple and testable +- Good accessibility support +- Active maintenance + +**Alternatives considered**: +- [tview](https://github.com/rivo/tview): More traditional, less modern feel +- Custom: Too much work, maintenance burden + +### Decision 4: WebSocket Protocol + +**What**: JSON messages over WebSocket with message types. + +```json +{ + "type": "status_update", + "repo": "myrepo", + "data": { /* RepoStatus JSON */ } +} +``` + +**Why**: Simple, standard, easy to consume from any language. + +**Alternatives considered**: +- gRPC streaming: Overkill for local use +- Server-Sent Events: Less bidirectional capability + +### Decision 5: Refresh Strategy + +**What**: Parallel worktree rebase with continue-on-failure. + +**Why**: +- Don't block all worktrees if one has conflicts +- Report all issues at once +- User can address conflicts selectively + +**Alternatives considered**: +- Sequential: Slower, stops at first failure +- Merge instead of rebase: Creates merge commits, messier history + +## Data Model Changes + +### State.json Extensions + +```go +type Repository struct { + // ... existing fields ... + + // New fields + Status RepoStatus `json:"status"` // active, hibernated + HibernatedAt *time.Time `json:"hibernated_at"` // when hibernated + HibernationData *HibernationData `json:"hibernation_data"` // preserved state +} + +type RepoStatus string + +const ( + RepoStatusActive RepoStatus = "active" + RepoStatusHibernated RepoStatus = "hibernated" + RepoStatusUninitialized RepoStatus = "uninitialized" +) + +type HibernationData struct { + Agents map[string]AgentConfig `json:"agents"` // agent configs to restore + Timestamp time.Time `json:"timestamp"` +} + +type AgentConfig struct { + Type AgentType `json:"type"` + Task string `json:"task,omitempty"` + Branch string `json:"branch,omitempty"` +} +``` + +## Risks / Trade-offs + +### Risk: Hibernation state becomes stale +- **Mitigation**: Warn if hibernation > 7 days old +- **Mitigation**: Offer `--fresh` flag to ignore hibernation state + +### Risk: WebSocket adds daemon complexity +- **Mitigation**: Make it opt-in (only when --websocket flag used) +- **Mitigation**: Separate goroutine, isolated from main daemon logic + +### Risk: TUI dependency adds bloat +- **Mitigation**: Lazy-load TUI (only import when --tui used) +- **Mitigation**: Consider making TUI a separate binary + +### Trade-off: Parallel refresh can leave partial state +- **Accepted**: Better than blocking. Clear error reporting mitigates. + +## Migration Plan + +1. **Phase 1** (this change): Core commands (start, status, hibernate, wake, refresh, clean) +2. **Phase 2**: TUI mode +3. **Phase 3**: WebSocket streaming + +No breaking changes. Existing commands continue to work. + +## Open Questions + +1. Should `repo start` be the default when running `multiclaude` with a repo argument? +2. Should hibernation auto-expire after N days? +3. Should WebSocket require authentication for security? diff --git a/openspec/changes/add-repo-lifecycle/proposal.md b/openspec/changes/add-repo-lifecycle/proposal.md new file mode 100644 index 0000000..eff377e --- /dev/null +++ b/openspec/changes/add-repo-lifecycle/proposal.md @@ -0,0 +1,48 @@ +# Change: Add Repo Lifecycle Management Commands + +## Why + +Currently, multiclaude has fragmented commands for managing repositories: +- `repo init/list/rm` for basic repo tracking +- `daemon start/stop` for the global daemon +- `worker create/list/rm` for individual workers +- `agents spawn` for persistent agents + +There's no unified way to: +1. **Start** a full repo session (supervisor + merge-queue + workspace) +2. Get **comprehensive status** of all repo activity +3. **Hibernate** a repo (pause agents without losing state) +4. **Refresh** all worktrees atomically +5. **Clean** orphaned resources for a specific repo + +Users must manually orchestrate these operations, leading to inconsistent states. + +## What Changes + +Add new `repo` subcommands for complete lifecycle management: + +| Command | Purpose | +|---------|---------| +| `repo start [name]` | Start all agents (supervisor, merge-queue, workspace) | +| `repo status [name]` | Comprehensive status with agents, PRs, messages, health | +| `repo hibernate [name]` | Pause all agents, preserve state | +| `repo wake [name]` | Resume hibernated repo | +| `repo refresh [name]` | Sync all worktrees with main branch | +| `repo clean [name]` | Clean orphaned resources for repo | + +Add output format options to all commands: +- `--format=text` (default) - Human-readable +- `--format=json` - Machine-readable JSON +- `--format=yaml` - YAML output +- `--tui` - Interactive terminal UI +- `--websocket` - Stream to WebSocket server + +## Impact + +- **Affected specs**: New capability (repo-lifecycle) +- **Affected code**: + - `multiclaude/internal/cli/cli.go` - New commands + - `multiclaude/internal/daemon/daemon.go` - Hibernate/wake support + - `multiclaude/internal/state/state.go` - Hibernation state +- **Breaking changes**: None (additive only) +- **Dependencies**: TUI requires new dependency (bubbletea or similar) diff --git a/openspec/changes/add-repo-lifecycle/specs/repo-lifecycle/spec.md b/openspec/changes/add-repo-lifecycle/specs/repo-lifecycle/spec.md new file mode 100644 index 0000000..f3595f1 --- /dev/null +++ b/openspec/changes/add-repo-lifecycle/specs/repo-lifecycle/spec.md @@ -0,0 +1,176 @@ +# Repo Lifecycle Management + +## ADDED Requirements + +### Requirement: Repo Start Command +The system SHALL provide a `repo start [name]` command that initializes all standard agents for a repository. + +#### Scenario: Start repo with default agents +- **WHEN** user runs `multiclaude repo start myrepo` +- **THEN** system spawns supervisor, merge-queue, and workspace agents +- **AND** all agents are running in tmux session `mc-myrepo` +- **AND** command returns success with agent summary + +#### Scenario: Start repo already running +- **WHEN** user runs `multiclaude repo start myrepo` on running repo +- **THEN** system reports current status without spawning duplicates +- **AND** suggests using `repo status` for detailed view + +#### Scenario: Start with specific agents +- **WHEN** user runs `multiclaude repo start myrepo --agents=supervisor,workspace` +- **THEN** system spawns only specified agents +- **AND** merge-queue is not started + +### Requirement: Repo Status Command +The system SHALL provide a `repo status [name]` command that displays comprehensive repository state. + +#### Scenario: Full status display +- **WHEN** user runs `multiclaude repo status myrepo` +- **THEN** system displays: + - Agent list with type, status, task, last activity + - Open PRs with mergeable state and CI status + - Pending messages count per agent + - Worktree sync status (ahead/behind main) + - Health indicators + +#### Scenario: Status with JSON output +- **WHEN** user runs `multiclaude repo status myrepo --format=json` +- **THEN** system outputs structured JSON with all status fields +- **AND** output is machine-parseable + +#### Scenario: Status with YAML output +- **WHEN** user runs `multiclaude repo status myrepo --format=yaml` +- **THEN** system outputs YAML-formatted status +- **AND** output follows standard YAML conventions + +#### Scenario: Status in TUI mode +- **WHEN** user runs `multiclaude repo status myrepo --tui` +- **THEN** system launches interactive terminal UI +- **AND** UI updates in real-time as state changes +- **AND** user can navigate with keyboard + +### Requirement: Repo Hibernate Command +The system SHALL provide a `repo hibernate [name]` command that pauses all agents while preserving state. + +#### Scenario: Hibernate active repo +- **WHEN** user runs `multiclaude repo hibernate myrepo` +- **THEN** system gracefully stops all agents +- **AND** agent state is saved to disk +- **AND** worktrees are preserved +- **AND** messages are preserved +- **AND** repo is marked as hibernated in state.json + +#### Scenario: Hibernate with timeout +- **WHEN** user runs `multiclaude repo hibernate myrepo --timeout=30s` +- **THEN** system waits up to 30s for graceful shutdown +- **AND** force-kills agents after timeout + +#### Scenario: Hibernate already hibernated repo +- **WHEN** user runs `multiclaude repo hibernate myrepo` on hibernated repo +- **THEN** system reports repo is already hibernated +- **AND** no changes are made + +### Requirement: Repo Wake Command +The system SHALL provide a `repo wake [name]` command that resumes a hibernated repository. + +#### Scenario: Wake hibernated repo +- **WHEN** user runs `multiclaude repo wake myrepo` +- **THEN** system restores all previously active agents +- **AND** agents resume with their saved state +- **AND** messages are delivered to awakened agents +- **AND** repo is marked as active + +#### Scenario: Wake with fresh state +- **WHEN** user runs `multiclaude repo wake myrepo --fresh` +- **THEN** system starts default agents (supervisor, merge-queue, workspace) +- **AND** previous agent state is discarded + +#### Scenario: Wake non-hibernated repo +- **WHEN** user runs `multiclaude repo wake myrepo` on active repo +- **THEN** system reports repo is already active +- **AND** suggests using `repo status` + +### Requirement: Repo Refresh Command +The system SHALL provide a `repo refresh [name]` command that syncs all worktrees with main branch. + +#### Scenario: Refresh all worktrees +- **WHEN** user runs `multiclaude repo refresh myrepo` +- **THEN** system fetches latest from remote +- **AND** rebases each worktree onto main +- **AND** reports success/failure per worktree + +#### Scenario: Refresh with conflicts +- **WHEN** user runs `multiclaude repo refresh myrepo` and conflicts exist +- **THEN** system reports which worktrees have conflicts +- **AND** provides resolution guidance +- **AND** does not abort other worktrees + +#### Scenario: Refresh specific worktree +- **WHEN** user runs `multiclaude repo refresh myrepo --agent=worker-1` +- **THEN** system refreshes only that agent's worktree + +### Requirement: Repo Clean Command +The system SHALL provide a `repo clean [name]` command that removes orphaned resources. + +#### Scenario: Clean orphaned worktrees +- **WHEN** user runs `multiclaude repo clean myrepo` +- **THEN** system identifies worktrees without active agents +- **AND** prompts for confirmation +- **AND** removes orphaned worktrees + +#### Scenario: Clean with dry-run +- **WHEN** user runs `multiclaude repo clean myrepo --dry-run` +- **THEN** system lists what would be cleaned +- **AND** does not remove anything + +#### Scenario: Clean with force +- **WHEN** user runs `multiclaude repo clean myrepo --force` +- **THEN** system removes orphaned resources without confirmation + +### Requirement: Output Format Options +The system SHALL support multiple output formats for all repo commands. + +#### Scenario: Text output (default) +- **WHEN** user runs any repo command without --format flag +- **THEN** output is human-readable text with formatting +- **AND** uses colors when terminal supports it + +#### Scenario: JSON output +- **WHEN** user runs repo command with `--format=json` +- **THEN** output is valid JSON +- **AND** includes all data fields +- **AND** is suitable for piping to jq + +#### Scenario: YAML output +- **WHEN** user runs repo command with `--format=yaml` +- **THEN** output is valid YAML +- **AND** uses standard YAML formatting + +#### Scenario: TUI mode +- **WHEN** user runs repo command with `--tui` +- **THEN** launches interactive terminal interface +- **AND** interface supports keyboard navigation +- **AND** updates in real-time for status commands + +#### Scenario: WebSocket streaming +- **WHEN** user runs `multiclaude repo status --websocket=:8080` +- **THEN** system starts WebSocket server on port 8080 +- **AND** streams status updates as JSON messages +- **AND** clients can connect and receive updates + +### Requirement: Repo List Enhancement +The system SHALL enhance `repo list` with status information and output formats. + +#### Scenario: List with status +- **WHEN** user runs `multiclaude repo list` +- **THEN** output includes for each repo: + - Name and GitHub URL + - Status (active/hibernated/uninitialized) + - Agent count and types + - Open PR count + - Last activity timestamp + +#### Scenario: List with format option +- **WHEN** user runs `multiclaude repo list --format=json` +- **THEN** output is JSON array of repo objects +- **AND** each object contains full status information diff --git a/openspec/changes/add-repo-lifecycle/tasks.md b/openspec/changes/add-repo-lifecycle/tasks.md new file mode 100644 index 0000000..78c8411 --- /dev/null +++ b/openspec/changes/add-repo-lifecycle/tasks.md @@ -0,0 +1,79 @@ +# Implementation Tasks + +## 1. Core Infrastructure + +- [ ] 1.1 Add `RepoState` enum to state.go (active, hibernated, uninitialized) +- [ ] 1.2 Add `HibernationState` struct to preserve agent configuration +- [ ] 1.3 Add output format types (text, json, yaml) to CLI +- [ ] 1.4 Create `OutputFormatter` interface for consistent formatting + +## 2. Repo Start Command + +- [ ] 2.1 Implement `repo start` command in cli.go +- [ ] 2.2 Add `--agents` flag for selective agent spawning +- [ ] 2.3 Add daemon socket handler for start operation +- [ ] 2.4 Add idempotency check (skip already running agents) +- [ ] 2.5 Write tests for start command + +## 3. Repo Status Command + +- [ ] 3.1 Implement `repo status` command in cli.go +- [ ] 3.2 Aggregate data: agents, PRs (via gh), messages, worktree sync +- [ ] 3.3 Implement text formatter with colors +- [ ] 3.4 Implement JSON formatter +- [ ] 3.5 Implement YAML formatter +- [ ] 3.6 Write tests for status command + +## 4. Repo Hibernate/Wake Commands + +- [ ] 4.1 Implement `repo hibernate` command +- [ ] 4.2 Add graceful agent shutdown with timeout +- [ ] 4.3 Save hibernation state to state.json +- [ ] 4.4 Implement `repo wake` command +- [ ] 4.5 Restore agents from hibernation state +- [ ] 4.6 Add `--fresh` flag for clean wake +- [ ] 4.7 Write tests for hibernate/wake cycle + +## 5. Repo Refresh Command + +- [ ] 5.1 Implement `repo refresh` command +- [ ] 5.2 Add parallel worktree rebase logic +- [ ] 5.3 Handle conflicts gracefully (continue others) +- [ ] 5.4 Add `--agent` flag for single worktree +- [ ] 5.5 Write tests for refresh command + +## 6. Repo Clean Command + +- [ ] 6.1 Implement `repo clean` command +- [ ] 6.2 Identify orphaned worktrees (no agent match) +- [ ] 6.3 Add confirmation prompt +- [ ] 6.4 Add `--dry-run` and `--force` flags +- [ ] 6.5 Write tests for clean command + +## 7. Repo List Enhancement + +- [ ] 7.1 Extend list output with status info +- [ ] 7.2 Add `--format` flag to list command +- [ ] 7.3 Write tests for enhanced list + +## 8. TUI Mode (Phase 2) + +- [ ] 8.1 Add bubbletea dependency +- [ ] 8.2 Create TUI model for status display +- [ ] 8.3 Implement real-time updates via state watcher +- [ ] 8.4 Add keyboard navigation +- [ ] 8.5 Write TUI tests + +## 9. WebSocket Streaming (Phase 2) + +- [ ] 9.1 Add WebSocket server to daemon +- [ ] 9.2 Implement status streaming endpoint +- [ ] 9.3 Add client connection management +- [ ] 9.4 Write WebSocket integration tests + +## 10. Documentation + +- [ ] 10.1 Update CLI docs with new commands +- [ ] 10.2 Add examples to README +- [ ] 10.3 Update COMMANDS.md reference +- [ ] 10.4 Run `go generate ./pkg/config` to regenerate docs diff --git a/pkg/config/config.go b/pkg/config/config.go index 309d0be..9bcf0c1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -19,6 +19,7 @@ type Paths struct { MessagesDir string // messages/ OutputDir string // output/ ClaudeConfigDir string // claude-config/ + ArchiveDir string // archive/ (for paused work) } // DefaultPaths returns the default paths for multiclaude @@ -41,6 +42,7 @@ func DefaultPaths() (*Paths, error) { MessagesDir: filepath.Join(root, "messages"), OutputDir: filepath.Join(root, "output"), ClaudeConfigDir: filepath.Join(root, "claude-config"), + ArchiveDir: filepath.Join(root, "archive"), }, nil } @@ -53,6 +55,7 @@ func (p *Paths) EnsureDirectories() error { p.MessagesDir, p.OutputDir, p.ClaudeConfigDir, + p.ArchiveDir, } for _, dir := range dirs { @@ -138,5 +141,11 @@ func NewTestPaths(tmpDir string) *Paths { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } } + +// RepoArchiveDir returns the path for a repository's archived work +func (p *Paths) RepoArchiveDir(repoName string) string { + return filepath.Join(p.ArchiveDir, repoName) +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 53c7135..4bc1e2b 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -60,6 +60,7 @@ func TestEnsureDirectories(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "test-multiclaude", "messages"), OutputDir: filepath.Join(tmpDir, "test-multiclaude", "output"), ClaudeConfigDir: filepath.Join(tmpDir, "test-multiclaude", "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "test-multiclaude", "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -67,7 +68,7 @@ func TestEnsureDirectories(t *testing.T) { } // Verify directories were created - dirs := []string{paths.Root, paths.ReposDir, paths.WorktreesDir, paths.MessagesDir, paths.OutputDir, paths.ClaudeConfigDir} + dirs := []string{paths.Root, paths.ReposDir, paths.WorktreesDir, paths.MessagesDir, paths.OutputDir, paths.ClaudeConfigDir, paths.ArchiveDir} for _, dir := range dirs { if _, err := os.Stat(dir); os.IsNotExist(err) { t.Errorf("Directory not created: %s", dir) diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh new file mode 100644 index 0000000..a1bc92e --- /dev/null +++ b/scripts/pre-commit.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Pre-commit hook for multiclaude +# Runs fast CI checks before allowing commit + +set -e + +echo "Running pre-commit checks..." +echo "" + +# Run pre-commit target (build + unit tests + verify docs) +# This skips the slower E2E tests for faster commits +if make pre-commit; then + echo "" + echo "✓ Pre-commit checks passed" + exit 0 +else + echo "" + echo "✗ Pre-commit checks failed" + echo "" + echo "Your commit has been blocked because local checks failed." + echo "Fix the issues above and try again." + echo "" + echo "To skip this hook (not recommended), use: git commit --no-verify" + echo "" + exit 1 +fi + diff --git a/test/agents_test.go b/test/agents_test.go index ab124a8..e2995df 100644 --- a/test/agents_test.go +++ b/test/agents_test.go @@ -50,6 +50,7 @@ func TestAgentTemplatesCopiedOnInit(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -130,8 +131,8 @@ func TestAgentTemplatesCopiedOnInit(t *testing.T) { } } -// TestAgentDefinitionMerging verifies that repo definitions override local definitions -// when both exist with the same name. +// TestAgentDefinitionMerging verifies that repo definitions are appended to local definitions +// when both exist with the same name (preserving base template instructions). func TestAgentDefinitionMerging(t *testing.T) { // Create temp directories tmpDir, err := os.MkdirTemp("", "agent-merge-test-*") @@ -148,8 +149,8 @@ func TestAgentDefinitionMerging(t *testing.T) { os.MkdirAll(localAgentsDir, 0755) os.MkdirAll(repoAgentsDir, 0755) - // Create local definition for "worker" - localWorkerContent := "# Worker (Local)\n\nThis is the local worker definition." + // Create local definition for "worker" (base template) + localWorkerContent := "# Worker (Local)\n\nThis is the local worker definition.\n\n## Critical Instructions\n\nRun `multiclaude agent complete` when done." if err := os.WriteFile(filepath.Join(localAgentsDir, "worker.md"), []byte(localWorkerContent), 0644); err != nil { t.Fatalf("Failed to write local worker: %v", err) } @@ -160,8 +161,8 @@ func TestAgentDefinitionMerging(t *testing.T) { t.Fatalf("Failed to write local-only: %v", err) } - // Create repo definition for "worker" (should override local) - repoWorkerContent := "# Worker (Repo Override)\n\nThis is the repo worker definition that overrides local." + // Create repo definition for "worker" (custom additions) + repoWorkerContent := "# Additional Team Instructions\n\nAlso follow the team coding style guide." if err := os.WriteFile(filepath.Join(repoAgentsDir, "worker.md"), []byte(repoWorkerContent), 0644); err != nil { t.Fatalf("Failed to write repo worker: %v", err) } @@ -190,13 +191,25 @@ func TestAgentDefinitionMerging(t *testing.T) { defMap[def.Name] = def } - // Test 1: "worker" should be from repo (overrides local) + // Test 1: "worker" should be merged (contains both local base AND repo custom content) if workerDef, ok := defMap["worker"]; ok { - if workerDef.Source != agents.SourceRepo { - t.Errorf("worker definition source = %s, want repo", workerDef.Source) + if workerDef.Source != agents.SourceMerged { + t.Errorf("worker definition source = %s, want merged", workerDef.Source) } - if !strings.Contains(workerDef.Content, "Repo Override") { - t.Error("worker definition should contain repo content, not local") + // Should contain LOCAL (base) content - critical instructions preserved + if !strings.Contains(workerDef.Content, "multiclaude agent complete") { + t.Error("merged worker should contain base template's critical instructions") + } + if !strings.Contains(workerDef.Content, "local worker definition") { + t.Error("merged worker should contain base template content") + } + // Should contain REPO (custom) content + if !strings.Contains(workerDef.Content, "team coding style guide") { + t.Error("merged worker should contain repo custom content") + } + // Should contain the separator + if !strings.Contains(workerDef.Content, "## Custom Instructions") { + t.Error("merged worker should contain the Custom Instructions separator") } } else { t.Error("worker definition should exist") @@ -386,6 +399,7 @@ func TestAgentsSpawnCommand(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -510,6 +524,7 @@ func TestAgentDefinitionsSentToSupervisor(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -580,8 +595,8 @@ func TestAgentDefinitionsSentToSupervisor(t *testing.T) { if def.Content == "" { t.Error("Definition content should not be empty") } - if def.Source != agents.SourceLocal && def.Source != agents.SourceRepo { - t.Errorf("Definition source = %s, want local or repo", def.Source) + if def.Source != agents.SourceLocal && def.Source != agents.SourceRepo && def.Source != agents.SourceMerged { + t.Errorf("Definition source = %s, want local, repo, or merged", def.Source) } } } @@ -615,6 +630,7 @@ func TestSpawnPersistentAgent(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -703,6 +719,7 @@ func TestSpawnEphemeralAgent(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { diff --git a/test/e2e_test.go b/test/e2e_test.go index 857113d..5a37506 100644 --- a/test/e2e_test.go +++ b/test/e2e_test.go @@ -48,6 +48,7 @@ func TestPhase2Integration(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { diff --git a/test/integration_test.go b/test/integration_test.go index d76b42f..9762912 100644 --- a/test/integration_test.go +++ b/test/integration_test.go @@ -54,6 +54,7 @@ func setupIntegrationTest(t *testing.T, repoName string) (*cli.CLI, *daemon.Daem MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -281,6 +282,7 @@ func TestRepoInitializationIntegration(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -442,6 +444,7 @@ func TestRepoInitializationWithMergeQueueDisabled(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { diff --git a/test/recovery_test.go b/test/recovery_test.go index 0f9b2cd..a6a9023 100644 --- a/test/recovery_test.go +++ b/test/recovery_test.go @@ -115,6 +115,7 @@ func TestOrphanedTmuxSessionCleanup(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -263,6 +264,7 @@ func TestStaleSocketCleanup(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil { @@ -380,6 +382,7 @@ func TestDaemonCrashRecovery(t *testing.T) { MessagesDir: filepath.Join(tmpDir, "messages"), OutputDir: filepath.Join(tmpDir, "output"), ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"), + ArchiveDir: filepath.Join(tmpDir, "archive"), } if err := paths.EnsureDirectories(); err != nil {