From 6200216c032feb3721d5d7a68cdb9ae86292bf32 Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Sat, 4 Apr 2026 22:40:49 -0400 Subject: [PATCH] feat: implement complete MCP tool suite (53 tools, 190+ tests) Phases 1-5 of the Go rewrite of cyborg-swarm: Phase 1 -- Hive + Swarm Mail: - 7 new hive tools (epic, query, start, ready, sync, sessions) - 10 swarm mail tools (init, send, inbox, reserve, release, ack) - 3 new DB tables (sessions, messages, reservations) Phase 2 -- Swarm Orchestration: - 24 swarm tools (decompose, spawn, worktree, review, insights) - Git worktree operations via os/exec - Prompt generators for decomposition, review, and evaluation Phase 3 -- Memory: - 8 memory tools (2 Dewey proxy + 6 deprecated stubs) - HTTP proxy client with structured DEWEY_UNAVAILABLE errors Phase 4 -- CLI: - doctor, stats, query, setup commands - Preset SQL analytics queries Phase 5 -- Parity Testing: - Shape comparison engine for JSON response validation - 20/20 tool fixtures match TypeScript response shapes Code review fixes: - Check DB error returns in swarm completion paths - Delete dead SQL injection code (joinStringsSQL) - Fix inbox LIKE query to use json_each() for exact matching - Use errors.Is(err, sql.ErrNoRows) idiomatically - Implement ready filter in hive_cells query - Pass build-time version to MCP server - Accept project_path in hive_sync tool 53 tools | 190+ tests | 15MB binary | zero runtime deps --- .specify/scripts/bash/check-prerequisites.sh | 0 .specify/scripts/bash/setup-plan.sh | 0 .specify/scripts/bash/update-agent-context.sh | 0 AGENTS.md | 7 + cmd/replicator/doctor.go | 58 ++ cmd/replicator/main.go | 60 ++ cmd/replicator/query.go | 33 + cmd/replicator/serve.go | 12 +- cmd/replicator/setup.go | 51 ++ cmd/replicator/stats.go | 21 + internal/db/db.go | 3 + internal/db/db_test.go | 2 +- internal/db/migrations.go | 47 + internal/doctor/checks.go | 178 ++++ internal/doctor/checks_test.go | 155 ++++ internal/gitutil/git.go | 127 +++ internal/gitutil/git_test.go | 196 +++++ internal/hive/cells.go | 79 +- internal/hive/epic.go | 107 +++ internal/hive/epic_test.go | 115 +++ internal/hive/session.go | 74 ++ internal/hive/session_test.go | 112 +++ internal/hive/start_ready_test.go | 118 +++ internal/hive/sync.go | 52 ++ internal/hive/sync_test.go | 108 +++ internal/mcp/server.go | 8 +- internal/mcp/server_test.go | 12 +- internal/memory/deprecated.go | 44 + internal/memory/deprecated_test.go | 95 ++ internal/memory/proxy.go | 185 ++++ internal/memory/proxy_test.go | 319 +++++++ internal/query/presets.go | 157 ++++ internal/query/presets_test.go | 224 +++++ internal/stats/stats.go | 124 +++ internal/stats/stats_test.go | 127 +++ internal/swarm/decompose.go | 196 +++++ internal/swarm/decompose_test.go | 175 ++++ internal/swarm/init.go | 50 ++ internal/swarm/init_test.go | 68 ++ internal/swarm/insights.go | 231 +++++ internal/swarm/insights_test.go | 155 ++++ internal/swarm/progress.go | 170 ++++ internal/swarm/progress_test.go | 148 ++++ internal/swarm/review.go | 211 +++++ internal/swarm/review_test.go | 175 ++++ internal/swarm/spawn.go | 115 +++ internal/swarm/spawn_test.go | 106 +++ internal/swarm/worktree.go | 121 +++ internal/swarm/worktree_test.go | 182 ++++ internal/swarmmail/agent.go | 60 ++ internal/swarmmail/agent_test.go | 73 ++ internal/swarmmail/message.go | 161 ++++ internal/swarmmail/message_test.go | 188 ++++ internal/swarmmail/reservation.go | 139 +++ internal/swarmmail/reservation_test.go | 166 ++++ internal/tools/hive/tools.go | 214 +++++ internal/tools/memory/tools.go | 118 +++ internal/tools/swarm/tools.go | 822 ++++++++++++++++++ internal/tools/swarmmail/tools.go | 321 +++++++ .../checklists/requirements.md | 38 + specs/001-go-rewrite-phases/plan.md | 499 +++++++++++ specs/001-go-rewrite-phases/quickstart.md | 182 ++++ specs/001-go-rewrite-phases/research.md | 225 +++++ specs/001-go-rewrite-phases/spec.md | 204 +++++ specs/001-go-rewrite-phases/tasks.md | 254 ++++++ test/parity/fixtures/hive.json | 38 + test/parity/fixtures/memory.json | 14 + test/parity/fixtures/swarm.json | 18 + test/parity/fixtures/swarmmail.json | 18 + test/parity/parity_test.go | 474 ++++++++++ test/parity/report.go | 59 ++ test/parity/shape.go | 161 ++++ test/parity/shape_test.go | 219 +++++ 73 files changed, 9767 insertions(+), 11 deletions(-) mode change 100644 => 100755 .specify/scripts/bash/check-prerequisites.sh mode change 100644 => 100755 .specify/scripts/bash/setup-plan.sh mode change 100644 => 100755 .specify/scripts/bash/update-agent-context.sh create mode 100644 cmd/replicator/doctor.go create mode 100644 cmd/replicator/query.go create mode 100644 cmd/replicator/setup.go create mode 100644 cmd/replicator/stats.go create mode 100644 internal/doctor/checks.go create mode 100644 internal/doctor/checks_test.go create mode 100644 internal/gitutil/git.go create mode 100644 internal/gitutil/git_test.go create mode 100644 internal/hive/epic.go create mode 100644 internal/hive/epic_test.go create mode 100644 internal/hive/session.go create mode 100644 internal/hive/session_test.go create mode 100644 internal/hive/start_ready_test.go create mode 100644 internal/hive/sync.go create mode 100644 internal/hive/sync_test.go create mode 100644 internal/memory/deprecated.go create mode 100644 internal/memory/deprecated_test.go create mode 100644 internal/memory/proxy.go create mode 100644 internal/memory/proxy_test.go create mode 100644 internal/query/presets.go create mode 100644 internal/query/presets_test.go create mode 100644 internal/stats/stats.go create mode 100644 internal/stats/stats_test.go create mode 100644 internal/swarm/decompose.go create mode 100644 internal/swarm/decompose_test.go create mode 100644 internal/swarm/init.go create mode 100644 internal/swarm/init_test.go create mode 100644 internal/swarm/insights.go create mode 100644 internal/swarm/insights_test.go create mode 100644 internal/swarm/progress.go create mode 100644 internal/swarm/progress_test.go create mode 100644 internal/swarm/review.go create mode 100644 internal/swarm/review_test.go create mode 100644 internal/swarm/spawn.go create mode 100644 internal/swarm/spawn_test.go create mode 100644 internal/swarm/worktree.go create mode 100644 internal/swarm/worktree_test.go create mode 100644 internal/swarmmail/agent.go create mode 100644 internal/swarmmail/agent_test.go create mode 100644 internal/swarmmail/message.go create mode 100644 internal/swarmmail/message_test.go create mode 100644 internal/swarmmail/reservation.go create mode 100644 internal/swarmmail/reservation_test.go create mode 100644 internal/tools/memory/tools.go create mode 100644 internal/tools/swarm/tools.go create mode 100644 internal/tools/swarmmail/tools.go create mode 100644 specs/001-go-rewrite-phases/checklists/requirements.md create mode 100644 specs/001-go-rewrite-phases/plan.md create mode 100644 specs/001-go-rewrite-phases/quickstart.md create mode 100644 specs/001-go-rewrite-phases/research.md create mode 100644 specs/001-go-rewrite-phases/spec.md create mode 100644 specs/001-go-rewrite-phases/tasks.md create mode 100644 test/parity/fixtures/hive.json create mode 100644 test/parity/fixtures/memory.json create mode 100644 test/parity/fixtures/swarm.json create mode 100644 test/parity/fixtures/swarmmail.json create mode 100644 test/parity/parity_test.go create mode 100644 test/parity/report.go create mode 100644 test/parity/shape.go create mode 100644 test/parity/shape_test.go diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh old mode 100644 new mode 100755 diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh old mode 100644 new mode 100755 diff --git a/.specify/scripts/bash/update-agent-context.sh b/.specify/scripts/bash/update-agent-context.sh old mode 100644 new mode 100755 diff --git a/AGENTS.md b/AGENTS.md index 997a307..989afd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,3 +76,10 @@ internal/ Go rewrite of [cyborg-swarm](https://github.com/unbound-force/cyborg-swarm), originally by [Joel Hooks](https://github.com/joelhooks). + +## Active Technologies +- Go 1.25+ + `cobra` (CLI), `modernc.org/sqlite` (pure Go SQLite), stdlib `encoding/json` (MCP JSON-RPC), stdlib `os/exec` (git operations) (001-go-rewrite-phases) +- SQLite at `~/.config/swarm-tools/swarm.db` (WAL mode, compatible with cyborg-swarm) (001-go-rewrite-phases) + +## Recent Changes +- 001-go-rewrite-phases: Added Go 1.25+ + `cobra` (CLI), `modernc.org/sqlite` (pure Go SQLite), stdlib `encoding/json` (MCP JSON-RPC), stdlib `os/exec` (git operations) diff --git a/cmd/replicator/doctor.go b/cmd/replicator/doctor.go new file mode 100644 index 0000000..b5cad4e --- /dev/null +++ b/cmd/replicator/doctor.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "time" + + "github.com/unbound-force/replicator/internal/config" + "github.com/unbound-force/replicator/internal/db" + "github.com/unbound-force/replicator/internal/doctor" +) + +// runDoctor executes health checks and prints results as a table. +func runDoctor(cfg *config.Config) error { + store, err := db.Open(cfg.DatabasePath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer store.Close() + + results, err := doctor.Run(store, cfg) + if err != nil { + return fmt.Errorf("run checks: %w", err) + } + + // Print header. + fmt.Printf("%-12s %-6s %s\n", "CHECK", "STATUS", "MESSAGE") + fmt.Printf("%-12s %-6s %s\n", "-----", "------", "-------") + + hasFailure := false + for _, r := range results { + icon := statusIcon(r.Status) + fmt.Printf("%-12s %s %-4s %s (%s)\n", r.Name, icon, r.Status, r.Message, r.Duration.Round(time.Millisecond)) + if r.Status == "fail" { + hasFailure = true + } + } + + if hasFailure { + fmt.Println("\nSome checks failed. Run 'replicator setup' to fix common issues.") + } else { + fmt.Println("\nAll checks passed.") + } + + return nil +} + +func statusIcon(status string) string { + switch status { + case "pass": + return "\u2713" // checkmark + case "fail": + return "\u2717" // X mark + case "warn": + return "!" // warning + default: + return "?" + } +} diff --git a/cmd/replicator/main.go b/cmd/replicator/main.go index 9a426ab..1011203 100644 --- a/cmd/replicator/main.go +++ b/cmd/replicator/main.go @@ -34,6 +34,10 @@ via Dewey).`, root.AddCommand(serveCmd()) root.AddCommand(cellsCmd()) root.AddCommand(versionCmd()) + root.AddCommand(doctorCmd()) + root.AddCommand(statsCmd()) + root.AddCommand(queryCmd()) + root.AddCommand(setupCmd()) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) @@ -73,3 +77,59 @@ func versionCmd() *cobra.Command { }, } } + +func doctorCmd() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Run health checks on the replicator environment", + Long: "Checks git, database, Dewey connectivity, and config directory.", + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.Load() + return runDoctor(cfg) + }, + } +} + +func statsCmd() *cobra.Command { + return &cobra.Command{ + Use: "stats", + Short: "Show database statistics", + Long: "Displays event counts, recent activity, and cell status summary.", + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.Load() + return runStats(cfg) + }, + } +} + +func queryCmd() *cobra.Command { + var listFlag bool + + cmd := &cobra.Command{ + Use: "query [preset]", + Short: "Run a preset database query", + Long: "Executes a named preset query against the database. Use --list to see available presets.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if listFlag || len(args) == 0 { + listQueryPresets() + return nil + } + cfg := config.Load() + return runQuery(cfg, args[0]) + }, + } + cmd.Flags().BoolVar(&listFlag, "list", false, "List available query presets") + return cmd +} + +func setupCmd() *cobra.Command { + return &cobra.Command{ + Use: "setup", + Short: "Initialize replicator environment", + Long: "Creates config directory, initializes database, and verifies git.", + RunE: func(cmd *cobra.Command, args []string) error { + return runSetup() + }, + } +} diff --git a/cmd/replicator/query.go b/cmd/replicator/query.go new file mode 100644 index 0000000..e581c16 --- /dev/null +++ b/cmd/replicator/query.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/unbound-force/replicator/internal/config" + "github.com/unbound-force/replicator/internal/db" + "github.com/unbound-force/replicator/internal/query" +) + +// runQuery executes a preset query and prints results. +func runQuery(cfg *config.Config, presetName string) error { + store, err := db.Open(cfg.DatabasePath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer store.Close() + + return query.Run(store, presetName, os.Stdout) +} + +// listQueryPresets prints available preset names. +func listQueryPresets() { + fmt.Println("Available query presets:") + for _, p := range query.ListPresets() { + fmt.Printf(" %s\n", p) + } + fmt.Println() + fmt.Println("Usage: replicator query ") + fmt.Printf("Example: replicator query %s\n", strings.Join(query.ListPresets()[:1], "")) +} diff --git a/cmd/replicator/serve.go b/cmd/replicator/serve.go index e5011b2..e73bd83 100644 --- a/cmd/replicator/serve.go +++ b/cmd/replicator/serve.go @@ -4,8 +4,12 @@ import ( "github.com/unbound-force/replicator/internal/config" "github.com/unbound-force/replicator/internal/db" "github.com/unbound-force/replicator/internal/mcp" + "github.com/unbound-force/replicator/internal/memory" "github.com/unbound-force/replicator/internal/tools/hive" + memorytools "github.com/unbound-force/replicator/internal/tools/memory" "github.com/unbound-force/replicator/internal/tools/registry" + swarmtools "github.com/unbound-force/replicator/internal/tools/swarm" + swarmmailtools "github.com/unbound-force/replicator/internal/tools/swarmmail" ) // serveMCP starts the MCP JSON-RPC server on stdio. @@ -20,7 +24,13 @@ func serveMCP() error { reg := registry.New() hive.Register(reg, store) + swarmmailtools.Register(reg, store) + swarmtools.Register(reg, store) - server := mcp.NewServer(reg) + // Memory tools proxy to Dewey for semantic search. + memClient := memory.NewClient(cfg.DeweyURL) + memorytools.Register(reg, memClient) + + server := mcp.NewServer(reg, Version) return server.ServeStdio() } diff --git a/cmd/replicator/setup.go b/cmd/replicator/setup.go new file mode 100644 index 0000000..3f79847 --- /dev/null +++ b/cmd/replicator/setup.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/unbound-force/replicator/internal/config" + "github.com/unbound-force/replicator/internal/db" +) + +// runSetup creates the config directory, initializes the database, and +// verifies git is available. +func runSetup() error { + // 1. Create config directory. + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("determine home directory: %w", err) + } + + configDir := filepath.Join(home, ".config", "swarm-tools") + if err := os.MkdirAll(configDir, 0o755); err != nil { + return fmt.Errorf("create config directory: %w", err) + } + fmt.Printf("\u2713 Config directory: %s\n", configDir) + + // 2. Initialize database. + cfg := config.Load() + store, err := db.Open(cfg.DatabasePath) + if err != nil { + return fmt.Errorf("initialize database: %w", err) + } + store.Close() + fmt.Printf("\u2713 Database: %s\n", cfg.DatabasePath) + + // 3. Verify git. + cmd := exec.Command("git", "--version") + out, err := cmd.Output() + if err != nil { + fmt.Printf("\u2717 Git: not found (%v)\n", err) + fmt.Println(" Install git: https://git-scm.com/downloads") + } else { + fmt.Printf("\u2713 Git: %s\n", strings.TrimSpace(string(out))) + } + + fmt.Println() + fmt.Println("Setup complete. Run 'replicator doctor' to verify all checks pass.") + return nil +} diff --git a/cmd/replicator/stats.go b/cmd/replicator/stats.go new file mode 100644 index 0000000..f0f5626 --- /dev/null +++ b/cmd/replicator/stats.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "os" + + "github.com/unbound-force/replicator/internal/config" + "github.com/unbound-force/replicator/internal/db" + "github.com/unbound-force/replicator/internal/stats" +) + +// runStats queries the database and prints statistics. +func runStats(cfg *config.Config) error { + store, err := db.Open(cfg.DatabasePath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer store.Close() + + return stats.Run(store, os.Stdout) +} diff --git a/internal/db/db.go b/internal/db/db.go index 2f78fdf..e03b262 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -78,4 +78,7 @@ var migrations = []string{ migrationAgents, migrationCells, migrationCellEvents, + migrationSessions, + migrationMessages, + migrationReservations, } diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 94936c8..b8dbe10 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -10,7 +10,7 @@ func TestOpenMemory(t *testing.T) { defer store.Close() // Verify tables exist. - tables := []string{"events", "agents", "beads", "cell_events"} + tables := []string{"events", "agents", "beads", "cell_events", "sessions", "messages", "reservations"} for _, table := range tables { var name string err := store.DB.QueryRow( diff --git a/internal/db/migrations.go b/internal/db/migrations.go index 337aadb..0bcec82 100644 --- a/internal/db/migrations.go +++ b/internal/db/migrations.go @@ -71,3 +71,50 @@ CREATE TABLE IF NOT EXISTS cell_events ( CREATE INDEX IF NOT EXISTS idx_cell_events_cell ON cell_events(cell_id); ` + +const migrationSessions = ` +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + agent_name TEXT, + project_path TEXT, + started_at TEXT, + ended_at TEXT, + handoff_notes TEXT, + active_cell_id TEXT +); +` + +const migrationMessages = ` +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_agent TEXT, + to_agents TEXT, + subject TEXT, + body TEXT, + importance TEXT DEFAULT 'normal', + thread_id TEXT, + ack_required INTEGER DEFAULT 0, + acknowledged INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_messages_from_agent ON messages(from_agent); +CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); +` + +const migrationReservations = ` +CREATE TABLE IF NOT EXISTS reservations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_name TEXT, + project_path TEXT, + path TEXT, + exclusive INTEGER DEFAULT 1, + reason TEXT, + ttl_seconds INTEGER DEFAULT 300, + created_at TEXT DEFAULT (datetime('now')), + expires_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_reservations_path ON reservations(path); +CREATE INDEX IF NOT EXISTS idx_reservations_agent ON reservations(agent_name); +` diff --git a/internal/doctor/checks.go b/internal/doctor/checks.go new file mode 100644 index 0000000..67e591a --- /dev/null +++ b/internal/doctor/checks.go @@ -0,0 +1,178 @@ +// Package doctor runs health checks for the replicator environment. +// +// Checks verify that required dependencies (git, database, Dewey, config dir) +// are available and functional. Results include pass/fail/warn status and +// timing for each check. +package doctor + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/unbound-force/replicator/internal/config" + "github.com/unbound-force/replicator/internal/db" +) + +// CheckResult holds the outcome of a single health check. +type CheckResult struct { + Name string `json:"name"` + Status string `json:"status"` // "pass", "fail", "warn" + Message string `json:"message"` + Duration time.Duration `json:"duration"` +} + +// Run executes all health checks and returns the results. +// Individual check failures do not stop subsequent checks. +func Run(store *db.Store, cfg *config.Config) ([]CheckResult, error) { + var results []CheckResult + + results = append(results, checkGit()) + results = append(results, checkDatabase(store)) + results = append(results, checkDewey(cfg.DeweyURL)) + results = append(results, checkConfigDir()) + + return results, nil +} + +// checkGit verifies that git is installed and returns its version. +func checkGit() CheckResult { + start := time.Now() + + cmd := exec.Command("git", "--version") + out, err := cmd.Output() + elapsed := time.Since(start) + + if err != nil { + return CheckResult{ + Name: "git", + Status: "fail", + Message: fmt.Sprintf("git not found: %v", err), + Duration: elapsed, + } + } + + version := strings.TrimSpace(string(out)) + return CheckResult{ + Name: "git", + Status: "pass", + Message: version, + Duration: elapsed, + } +} + +// checkDatabase verifies the SQLite database is accessible. +func checkDatabase(store *db.Store) CheckResult { + start := time.Now() + + err := store.DB.Ping() + elapsed := time.Since(start) + + if err != nil { + return CheckResult{ + Name: "database", + Status: "fail", + Message: fmt.Sprintf("database ping failed: %v", err), + Duration: elapsed, + } + } + + return CheckResult{ + Name: "database", + Status: "pass", + Message: "SQLite database is accessible", + Duration: elapsed, + } +} + +// checkDewey verifies the Dewey semantic search endpoint is reachable. +// Uses a simple HTTP GET -- a non-200 response is a warning, not a failure, +// because Dewey is optional for core operations. +func checkDewey(deweyURL string) CheckResult { + start := time.Now() + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(deweyURL) + elapsed := time.Since(start) + + if err != nil { + return CheckResult{ + Name: "dewey", + Status: "warn", + Message: fmt.Sprintf("Dewey not reachable at %s: %v", deweyURL, err), + Duration: elapsed, + } + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return CheckResult{ + Name: "dewey", + Status: "warn", + Message: fmt.Sprintf("Dewey returned HTTP %d at %s", resp.StatusCode, deweyURL), + Duration: elapsed, + } + } + + return CheckResult{ + Name: "dewey", + Status: "pass", + Message: fmt.Sprintf("Dewey is reachable at %s", deweyURL), + Duration: elapsed, + } +} + +// checkConfigDir verifies the config directory exists. +func checkConfigDir() CheckResult { + start := time.Now() + + home, err := os.UserHomeDir() + if err != nil { + elapsed := time.Since(start) + return CheckResult{ + Name: "config_dir", + Status: "fail", + Message: fmt.Sprintf("cannot determine home directory: %v", err), + Duration: elapsed, + } + } + + configDir := home + "/.config/swarm-tools" + info, err := os.Stat(configDir) + elapsed := time.Since(start) + + if os.IsNotExist(err) { + return CheckResult{ + Name: "config_dir", + Status: "fail", + Message: fmt.Sprintf("config directory does not exist: %s", configDir), + Duration: elapsed, + } + } + if err != nil { + return CheckResult{ + Name: "config_dir", + Status: "fail", + Message: fmt.Sprintf("cannot access config directory: %v", err), + Duration: elapsed, + } + } + if !info.IsDir() { + return CheckResult{ + Name: "config_dir", + Status: "fail", + Message: fmt.Sprintf("%s exists but is not a directory", configDir), + Duration: elapsed, + } + } + + return CheckResult{ + Name: "config_dir", + Status: "pass", + Message: fmt.Sprintf("config directory exists: %s", configDir), + Duration: elapsed, + } +} diff --git a/internal/doctor/checks_test.go b/internal/doctor/checks_test.go new file mode 100644 index 0000000..8f599c0 --- /dev/null +++ b/internal/doctor/checks_test.go @@ -0,0 +1,155 @@ +package doctor + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/unbound-force/replicator/internal/config" + "github.com/unbound-force/replicator/internal/db" +) + +func testStore(t *testing.T) *db.Store { + t.Helper() + store, err := db.OpenMemory() + if err != nil { + t.Fatalf("OpenMemory: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +func TestRun_AllChecks(t *testing.T) { + store := testStore(t) + + // Mock Dewey as healthy. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "healthy"}`)) + })) + defer srv.Close() + + cfg := &config.Config{ + DeweyURL: srv.URL, + } + + results, err := Run(store, cfg) + if err != nil { + t.Fatalf("Run: %v", err) + } + + if len(results) != 4 { + t.Fatalf("expected 4 checks, got %d", len(results)) + } + + // Verify check names. + names := make(map[string]bool) + for _, r := range results { + names[r.Name] = true + } + for _, expected := range []string{"git", "database", "dewey", "config_dir"} { + if !names[expected] { + t.Errorf("missing check: %s", expected) + } + } +} + +func TestCheckGit(t *testing.T) { + if testing.Short() { + t.Skip("requires git") + } + + result := checkGit() + if result.Name != "git" { + t.Errorf("name = %q, want %q", result.Name, "git") + } + if result.Status != "pass" { + t.Errorf("status = %q, want %q (message: %s)", result.Status, "pass", result.Message) + } + if result.Duration <= 0 { + t.Error("duration should be positive") + } +} + +func TestCheckDatabase_Healthy(t *testing.T) { + store := testStore(t) + + result := checkDatabase(store) + if result.Status != "pass" { + t.Errorf("status = %q, want %q (message: %s)", result.Status, "pass", result.Message) + } +} + +func TestCheckDatabase_Closed(t *testing.T) { + store := testStore(t) + store.Close() + + result := checkDatabase(store) + if result.Status != "fail" { + t.Errorf("status = %q, want %q for closed database", result.Status, "fail") + } +} + +func TestCheckDewey_Healthy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + result := checkDewey(srv.URL) + if result.Status != "pass" { + t.Errorf("status = %q, want %q", result.Status, "pass") + } +} + +func TestCheckDewey_Unreachable(t *testing.T) { + result := checkDewey("http://127.0.0.1:1") + if result.Status != "warn" { + t.Errorf("status = %q, want %q for unreachable Dewey", result.Status, "warn") + } +} + +func TestCheckDewey_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + result := checkDewey(srv.URL) + if result.Status != "warn" { + t.Errorf("status = %q, want %q for HTTP 500", result.Status, "warn") + } +} + +func TestCheckConfigDir(t *testing.T) { + result := checkConfigDir() + // The config dir may or may not exist in CI, but the check should not panic. + if result.Name != "config_dir" { + t.Errorf("name = %q, want %q", result.Name, "config_dir") + } + if result.Status != "pass" && result.Status != "fail" { + t.Errorf("status = %q, want pass or fail", result.Status) + } + if result.Duration <= 0 { + t.Error("duration should be positive") + } +} + +func TestCheckResult_StatusValues(t *testing.T) { + // Verify that all results use valid status values. + store := testStore(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := &config.Config{DeweyURL: srv.URL} + results, _ := Run(store, cfg) + + validStatuses := map[string]bool{"pass": true, "fail": true, "warn": true} + for _, r := range results { + if !validStatuses[r.Status] { + t.Errorf("check %q has invalid status %q", r.Name, r.Status) + } + } +} diff --git a/internal/gitutil/git.go b/internal/gitutil/git.go new file mode 100644 index 0000000..cf2d10f --- /dev/null +++ b/internal/gitutil/git.go @@ -0,0 +1,127 @@ +// Package gitutil provides thin wrappers around git commands for worktree +// management and commit operations. +// +// All functions shell out to the `git` binary. Tests use t.TempDir() with +// `git init` to create isolated repositories. +package gitutil + +import ( + "fmt" + "os/exec" + "strings" +) + +// WorktreeInfo describes a single git worktree entry. +type WorktreeInfo struct { + Path string `json:"path"` + Branch string `json:"branch"` + Commit string `json:"commit"` +} + +// Run executes a git command in the given directory, returning stdout. +// If the command fails, the error includes stderr for diagnostics. +func Run(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git %s: %w\nstderr: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return strings.TrimSpace(stdout.String()), nil +} + +// WorktreeAdd creates a new git worktree at worktreePath on the given branch, +// starting from startCommit. +func WorktreeAdd(projectPath, worktreePath, branch, startCommit string) error { + _, err := Run(projectPath, "worktree", "add", "-b", branch, worktreePath, startCommit) + if err != nil { + return fmt.Errorf("worktree add: %w", err) + } + return nil +} + +// WorktreeRemove removes a git worktree at the given path. +// projectPath is the main repository directory from which the worktree was created. +func WorktreeRemove(projectPath, worktreePath string) error { + _, err := Run(projectPath, "worktree", "remove", worktreePath, "--force") + if err != nil { + return fmt.Errorf("worktree remove %s: %w", worktreePath, err) + } + return nil +} + +// WorktreeList parses `git worktree list --porcelain` output into structured info. +func WorktreeList(projectPath string) ([]WorktreeInfo, error) { + out, err := Run(projectPath, "worktree", "list", "--porcelain") + if err != nil { + return nil, fmt.Errorf("worktree list: %w", err) + } + + var worktrees []WorktreeInfo + var current WorktreeInfo + + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "worktree "): + current.Path = strings.TrimPrefix(line, "worktree ") + case strings.HasPrefix(line, "HEAD "): + current.Commit = strings.TrimPrefix(line, "HEAD ") + case strings.HasPrefix(line, "branch "): + current.Branch = strings.TrimPrefix(line, "branch ") + case line == "" || line == "bare": + // End of a worktree block -- save if we have a path. + if current.Path != "" { + worktrees = append(worktrees, current) + } + current = WorktreeInfo{} + } + } + // Capture the last entry if the output doesn't end with a blank line. + if current.Path != "" { + worktrees = append(worktrees, current) + } + + if worktrees == nil { + worktrees = []WorktreeInfo{} + } + return worktrees, nil +} + +// CherryPick identifies commits on worktreeBranch since startCommit and +// cherry-picks each into the current branch of projectPath. +func CherryPick(projectPath, worktreeBranch, startCommit string) error { + // List commits from startCommit (exclusive) to the tip of worktreeBranch. + out, err := Run(projectPath, "log", "--reverse", "--format=%H", startCommit+".."+worktreeBranch) + if err != nil { + return fmt.Errorf("list commits: %w", err) + } + + if out == "" { + return nil // No commits to cherry-pick. + } + + for _, commit := range strings.Split(out, "\n") { + commit = strings.TrimSpace(commit) + if commit == "" { + continue + } + if _, err := Run(projectPath, "cherry-pick", commit); err != nil { + return fmt.Errorf("cherry-pick %s: %w", commit[:8], err) + } + } + return nil +} + +// CurrentCommit returns the HEAD commit SHA for the given project path. +func CurrentCommit(projectPath string) (string, error) { + out, err := Run(projectPath, "rev-parse", "HEAD") + if err != nil { + return "", fmt.Errorf("current commit: %w", err) + } + return out, nil +} diff --git a/internal/gitutil/git_test.go b/internal/gitutil/git_test.go new file mode 100644 index 0000000..7702587 --- /dev/null +++ b/internal/gitutil/git_test.go @@ -0,0 +1,196 @@ +package gitutil + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// initRepo creates a temporary git repo with an initial commit. +// Returns the repo path. Skips if testing.Short() is set. +func initRepo(t *testing.T) string { + t.Helper() + if testing.Short() { + t.Skip("requires git") + } + + dir := t.TempDir() + run := func(args ...string) { + t.Helper() + if _, err := Run(dir, args...); err != nil { + t.Fatalf("git %s: %v", strings.Join(args, " "), err) + } + } + + run("init") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + // Create an initial commit so HEAD exists. + f := filepath.Join(dir, "README.md") + if err := os.WriteFile(f, []byte("# test\n"), 0o644); err != nil { + t.Fatal(err) + } + run("add", ".") + run("commit", "-m", "initial commit") + + return dir +} + +func TestRun(t *testing.T) { + if testing.Short() { + t.Skip("requires git") + } + dir := t.TempDir() + if _, err := Run(dir, "init"); err != nil { + t.Fatalf("git init: %v", err) + } + out, err := Run(dir, "status") + if err != nil { + t.Fatalf("git status: %v", err) + } + if !strings.Contains(out, "nothing to commit") && !strings.Contains(out, "No commits yet") { + t.Errorf("unexpected status output: %s", out) + } +} + +func TestRun_Error(t *testing.T) { + if testing.Short() { + t.Skip("requires git") + } + dir := t.TempDir() + _, err := Run(dir, "log") + if err == nil { + t.Error("expected error for git log in non-repo") + } +} + +func TestCurrentCommit(t *testing.T) { + repo := initRepo(t) + + sha, err := CurrentCommit(repo) + if err != nil { + t.Fatalf("CurrentCommit: %v", err) + } + if len(sha) != 40 { + t.Errorf("expected 40-char SHA, got %d chars: %s", len(sha), sha) + } +} + +func TestWorktreeAdd_And_List(t *testing.T) { + repo := initRepo(t) + + commit, _ := CurrentCommit(repo) + wtPath := filepath.Join(t.TempDir(), "wt-test") + + err := WorktreeAdd(repo, wtPath, "wt-branch", commit) + if err != nil { + t.Fatalf("WorktreeAdd: %v", err) + } + + // Verify the worktree directory exists. + if _, err := os.Stat(wtPath); os.IsNotExist(err) { + t.Fatal("worktree directory was not created") + } + + // List worktrees. + worktrees, err := WorktreeList(repo) + if err != nil { + t.Fatalf("WorktreeList: %v", err) + } + if len(worktrees) < 2 { + t.Fatalf("expected at least 2 worktrees (main + new), got %d", len(worktrees)) + } + + // Find our worktree -- resolve symlinks for macOS /var -> /private/var. + resolvedWtPath, _ := filepath.EvalSymlinks(wtPath) + found := false + for _, wt := range worktrees { + if wt.Path == wtPath || wt.Path == resolvedWtPath { + found = true + if !strings.HasSuffix(wt.Branch, "wt-branch") { + t.Errorf("branch = %q, want suffix %q", wt.Branch, "wt-branch") + } + } + } + if !found { + t.Errorf("worktree %q (resolved: %q) not found in list: %+v", wtPath, resolvedWtPath, worktrees) + } +} + +func TestWorktreeRemove(t *testing.T) { + repo := initRepo(t) + + commit, _ := CurrentCommit(repo) + wtPath := filepath.Join(t.TempDir(), "wt-remove") + + WorktreeAdd(repo, wtPath, "remove-branch", commit) + + err := WorktreeRemove(repo, wtPath) + if err != nil { + t.Fatalf("WorktreeRemove: %v", err) + } + + // Verify directory is gone. + if _, err := os.Stat(wtPath); !os.IsNotExist(err) { + t.Error("worktree directory should be removed") + } +} + +func TestCherryPick(t *testing.T) { + repo := initRepo(t) + + startCommit, _ := CurrentCommit(repo) + + // Create a worktree and make a commit in it. + wtPath := filepath.Join(t.TempDir(), "wt-cherry") + WorktreeAdd(repo, wtPath, "cherry-branch", startCommit) + + // Make a commit in the worktree. + f := filepath.Join(wtPath, "new-file.txt") + os.WriteFile(f, []byte("cherry content\n"), 0o644) + Run(wtPath, "add", ".") + Run(wtPath, "commit", "-m", "cherry commit") + + // Cherry-pick from worktree branch back to main. + err := CherryPick(repo, "cherry-branch", startCommit) + if err != nil { + t.Fatalf("CherryPick: %v", err) + } + + // Verify the file exists in the main repo. + mainFile := filepath.Join(repo, "new-file.txt") + if _, err := os.Stat(mainFile); os.IsNotExist(err) { + t.Error("cherry-picked file should exist in main repo") + } +} + +func TestCherryPick_NoCommits(t *testing.T) { + repo := initRepo(t) + + startCommit, _ := CurrentCommit(repo) + + // Create a worktree with no new commits. + wtPath := filepath.Join(t.TempDir(), "wt-empty") + WorktreeAdd(repo, wtPath, "empty-branch", startCommit) + + // Cherry-pick should succeed with no-op. + err := CherryPick(repo, "empty-branch", startCommit) + if err != nil { + t.Fatalf("CherryPick with no commits: %v", err) + } +} + +func TestWorktreeList_Empty(t *testing.T) { + repo := initRepo(t) + + worktrees, err := WorktreeList(repo) + if err != nil { + t.Fatalf("WorktreeList: %v", err) + } + // Should have at least the main worktree. + if len(worktrees) < 1 { + t.Errorf("expected at least 1 worktree, got %d", len(worktrees)) + } +} diff --git a/internal/hive/cells.go b/internal/hive/cells.go index 36f6f81..629e9da 100644 --- a/internal/hive/cells.go +++ b/internal/hive/cells.go @@ -8,8 +8,10 @@ package hive import ( "crypto/rand" + "database/sql" "encoding/hex" "encoding/json" + "errors" "fmt" "time" @@ -17,14 +19,18 @@ import ( ) // Cell represents a single work item in the hive. +// +// Fields like Description and ParentID are always serialized (no omitempty) +// to maintain shape parity with the TypeScript cyborg-swarm responses. +// Optional fields that are truly absent use pointer types with explicit null. type Cell struct { ID string `json:"id"` Title string `json:"title"` - Description string `json:"description,omitempty"` + Description string `json:"description"` Type string `json:"type"` Status string `json:"status"` Priority int `json:"priority"` - ParentID *string `json:"parent_id,omitempty"` + ParentID *string `json:"parent_id"` ProjectKey string `json:"project_key,omitempty"` AssignedTo *string `json:"assigned_to,omitempty"` Labels []string `json:"labels,omitempty"` @@ -111,6 +117,20 @@ func QueryCells(store *db.Store, q CellQuery) ([]Cell, error) { args = append(args, q.Type) } + // When Ready is true, filter to cells that are open, not epics, and whose + // parent (if any) is not in an unfinished state. This mirrors ReadyCell + // logic but returns multiple results. + if q.Ready { + query += ` AND status = 'open' + AND type != 'epic' + AND (parent_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM beads p + WHERE p.id = beads.parent_id + AND p.status IN ('open', 'in_progress', 'blocked') + ))` + } + query += " ORDER BY priority DESC, created_at DESC" limit := q.Limit @@ -200,6 +220,61 @@ func UpdateCell(store *db.Store, id string, status *string, description *string, return nil } +// StartCell sets a cell's status to in_progress. +func StartCell(store *db.Store, id string) error { + now := time.Now().UTC().Format(time.RFC3339) + result, err := store.DB.Exec( + "UPDATE beads SET status = 'in_progress', updated_at = ? WHERE id = ?", + now, id, + ) + if err != nil { + return fmt.Errorf("start cell: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("cell %q not found", id) + } + return nil +} + +// ReadyCell finds the first unblocked open cell -- one whose parent (if any) +// is not in an open, in_progress, or blocked state. Results are ordered by +// priority DESC so the highest-priority ready cell is returned. +func ReadyCell(store *db.Store) (*Cell, error) { + // Exclude epics from ready results -- they are containers, not actionable work. + row := store.DB.QueryRow(` + SELECT b.id, b.title, b.description, b.type, b.status, b.priority, + b.parent_id, b.created_at, b.updated_at + FROM beads b + WHERE b.status = 'open' + AND b.type != 'epic' + AND (b.parent_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM beads p + WHERE p.id = b.parent_id + AND p.status IN ('open', 'in_progress', 'blocked') + )) + ORDER BY b.priority DESC, b.created_at ASC + LIMIT 1 + `) + + var c Cell + var desc, parentID *string + err := row.Scan(&c.ID, &c.Title, &desc, &c.Type, &c.Status, &c.Priority, + &parentID, &c.CreatedAt, &c.UpdatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("ready cell: %w", err) + } + if desc != nil { + c.Description = *desc + } + c.ParentID = parentID + return &c, nil +} + func generateID() (string, error) { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { diff --git a/internal/hive/epic.go b/internal/hive/epic.go new file mode 100644 index 0000000..e0cb6d2 --- /dev/null +++ b/internal/hive/epic.go @@ -0,0 +1,107 @@ +package hive + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/unbound-force/replicator/internal/db" +) + +// SubtaskInput defines a subtask to create under an epic. +type SubtaskInput struct { + Title string `json:"title"` + Priority int `json:"priority,omitempty"` + Files []string `json:"files,omitempty"` +} + +// CreateEpicInput defines the input for creating an epic with subtasks. +type CreateEpicInput struct { + EpicTitle string `json:"epic_title"` + EpicDescription string `json:"epic_description,omitempty"` + Subtasks []SubtaskInput `json:"subtasks"` +} + +// CreateEpic atomically creates an epic cell and its subtasks in a single transaction. +func CreateEpic(store *db.Store, input CreateEpicInput) (*Cell, []Cell, error) { + tx, err := store.DB.Begin() + if err != nil { + return nil, nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + epicID, err := generateID() + if err != nil { + return nil, nil, fmt.Errorf("generate epic ID: %w", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + labels, _ := json.Marshal([]string{}) + + _, err = tx.Exec(` + INSERT INTO beads (id, title, description, type, status, priority, labels, created_at, updated_at) + VALUES (?, ?, ?, 'epic', 'open', 1, ?, ?, ?)`, + epicID, input.EpicTitle, input.EpicDescription, string(labels), now, now, + ) + if err != nil { + return nil, nil, fmt.Errorf("insert epic: %w", err) + } + + epic := &Cell{ + ID: epicID, + Title: input.EpicTitle, + Description: input.EpicDescription, + Type: "epic", + Status: "open", + Priority: 1, + CreatedAt: now, + UpdatedAt: now, + } + + subtasks := make([]Cell, 0, len(input.Subtasks)) + for _, st := range input.Subtasks { + subID, err := generateID() + if err != nil { + return nil, nil, fmt.Errorf("generate subtask ID: %w", err) + } + + priority := st.Priority + if priority == 0 { + priority = 1 + } + + // Store files list in the metadata JSON field. + metadata := "{}" + if len(st.Files) > 0 { + filesJSON, _ := json.Marshal(st.Files) + metadata = fmt.Sprintf(`{"files":%s}`, string(filesJSON)) + } + + _, err = tx.Exec(` + INSERT INTO beads (id, title, type, status, priority, parent_id, labels, metadata, created_at, updated_at) + VALUES (?, ?, 'task', 'open', ?, ?, ?, ?, ?, ?)`, + subID, st.Title, priority, epicID, string(labels), metadata, now, now, + ) + if err != nil { + return nil, nil, fmt.Errorf("insert subtask %q: %w", st.Title, err) + } + + parentID := epicID + subtasks = append(subtasks, Cell{ + ID: subID, + Title: st.Title, + Type: "task", + Status: "open", + Priority: priority, + ParentID: &parentID, + CreatedAt: now, + UpdatedAt: now, + }) + } + + if err := tx.Commit(); err != nil { + return nil, nil, fmt.Errorf("commit tx: %w", err) + } + + return epic, subtasks, nil +} diff --git a/internal/hive/epic_test.go b/internal/hive/epic_test.go new file mode 100644 index 0000000..cc08dea --- /dev/null +++ b/internal/hive/epic_test.go @@ -0,0 +1,115 @@ +package hive + +import "testing" + +func TestCreateEpic(t *testing.T) { + store := testStore(t) + + epic, subtasks, err := CreateEpic(store, CreateEpicInput{ + EpicTitle: "Build the thing", + EpicDescription: "A big feature", + Subtasks: []SubtaskInput{ + {Title: "Step 1", Priority: 2}, + {Title: "Step 2", Priority: 1, Files: []string{"foo.go", "bar.go"}}, + }, + }) + if err != nil { + t.Fatalf("CreateEpic: %v", err) + } + + if epic.Title != "Build the thing" { + t.Errorf("epic title = %q, want %q", epic.Title, "Build the thing") + } + if epic.Type != "epic" { + t.Errorf("epic type = %q, want %q", epic.Type, "epic") + } + if epic.Status != "open" { + t.Errorf("epic status = %q, want %q", epic.Status, "open") + } + + if len(subtasks) != 2 { + t.Fatalf("expected 2 subtasks, got %d", len(subtasks)) + } + if subtasks[0].Title != "Step 1" { + t.Errorf("subtask[0] title = %q, want %q", subtasks[0].Title, "Step 1") + } + if subtasks[0].Priority != 2 { + t.Errorf("subtask[0] priority = %d, want %d", subtasks[0].Priority, 2) + } + if subtasks[0].ParentID == nil || *subtasks[0].ParentID != epic.ID { + t.Errorf("subtask[0] parent_id = %v, want %q", subtasks[0].ParentID, epic.ID) + } + if subtasks[1].Title != "Step 2" { + t.Errorf("subtask[1] title = %q, want %q", subtasks[1].Title, "Step 2") + } +} + +func TestCreateEpic_NoSubtasks(t *testing.T) { + store := testStore(t) + + epic, subtasks, err := CreateEpic(store, CreateEpicInput{ + EpicTitle: "Empty epic", + }) + if err != nil { + t.Fatalf("CreateEpic: %v", err) + } + if epic.Title != "Empty epic" { + t.Errorf("epic title = %q, want %q", epic.Title, "Empty epic") + } + if len(subtasks) != 0 { + t.Errorf("expected 0 subtasks, got %d", len(subtasks)) + } +} + +func TestCreateEpic_SubtasksQueryable(t *testing.T) { + store := testStore(t) + + epic, _, err := CreateEpic(store, CreateEpicInput{ + EpicTitle: "Queryable epic", + Subtasks: []SubtaskInput{ + {Title: "Sub A"}, + {Title: "Sub B"}, + }, + }) + if err != nil { + t.Fatalf("CreateEpic: %v", err) + } + + // Query all cells -- should find epic + 2 subtasks. + cells, err := QueryCells(store, CellQuery{}) + if err != nil { + t.Fatalf("QueryCells: %v", err) + } + if len(cells) != 3 { + t.Errorf("expected 3 cells (1 epic + 2 subtasks), got %d", len(cells)) + } + + // Query by epic type. + epics, err := QueryCells(store, CellQuery{Type: "epic"}) + if err != nil { + t.Fatalf("QueryCells: %v", err) + } + if len(epics) != 1 { + t.Errorf("expected 1 epic, got %d", len(epics)) + } + if epics[0].ID != epic.ID { + t.Errorf("epic ID = %q, want %q", epics[0].ID, epic.ID) + } +} + +func TestCreateEpic_DefaultPriority(t *testing.T) { + store := testStore(t) + + _, subtasks, err := CreateEpic(store, CreateEpicInput{ + EpicTitle: "Priority test", + Subtasks: []SubtaskInput{ + {Title: "No priority set"}, + }, + }) + if err != nil { + t.Fatalf("CreateEpic: %v", err) + } + if subtasks[0].Priority != 1 { + t.Errorf("default priority = %d, want 1", subtasks[0].Priority) + } +} diff --git a/internal/hive/session.go b/internal/hive/session.go new file mode 100644 index 0000000..a7af550 --- /dev/null +++ b/internal/hive/session.go @@ -0,0 +1,74 @@ +package hive + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "time" + + "github.com/unbound-force/replicator/internal/db" +) + +// SessionStart creates a new session and returns the previous session's +// handoff notes (if any). This enables context preservation across sessions. +func SessionStart(store *db.Store, activeCellID string) (string, error) { + // Find the most recent ended session's handoff notes. + // Use rowid for deterministic ordering when timestamps collide. + var prevNotes string + err := store.DB.QueryRow( + "SELECT handoff_notes FROM sessions WHERE ended_at IS NOT NULL ORDER BY rowid DESC LIMIT 1", + ).Scan(&prevNotes) + if err != nil && err != sql.ErrNoRows { + return "", fmt.Errorf("query previous session: %w", err) + } + + sessionID, err := generateSessionID() + if err != nil { + return "", fmt.Errorf("generate session ID: %w", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + var cellID *string + if activeCellID != "" { + cellID = &activeCellID + } + + _, err = store.DB.Exec( + "INSERT INTO sessions (session_id, started_at, active_cell_id) VALUES (?, ?, ?)", + sessionID, now, cellID, + ) + if err != nil { + return "", fmt.Errorf("insert session: %w", err) + } + + return prevNotes, nil +} + +// SessionEnd ends the current (most recent un-ended) session with handoff notes. +func SessionEnd(store *db.Store, handoffNotes string) error { + now := time.Now().UTC().Format(time.RFC3339) + result, err := store.DB.Exec(` + UPDATE sessions SET ended_at = ?, handoff_notes = ? + WHERE session_id = ( + SELECT session_id FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1 + )`, + now, handoffNotes, + ) + if err != nil { + return fmt.Errorf("end session: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("no active session to end") + } + return nil +} + +func generateSessionID() (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "sess-" + hex.EncodeToString(b), nil +} diff --git a/internal/hive/session_test.go b/internal/hive/session_test.go new file mode 100644 index 0000000..f0385f5 --- /dev/null +++ b/internal/hive/session_test.go @@ -0,0 +1,112 @@ +package hive + +import "testing" + +func TestSessionStart_NoHistory(t *testing.T) { + store := testStore(t) + + notes, err := SessionStart(store, "") + if err != nil { + t.Fatalf("SessionStart: %v", err) + } + if notes != "" { + t.Errorf("expected empty handoff notes, got %q", notes) + } +} + +func TestSessionStart_WithActiveCellID(t *testing.T) { + store := testStore(t) + + cell, _ := CreateCell(store, CreateCellInput{Title: "Active task"}) + notes, err := SessionStart(store, cell.ID) + if err != nil { + t.Fatalf("SessionStart: %v", err) + } + if notes != "" { + t.Errorf("expected empty handoff notes, got %q", notes) + } + + // Verify session was created. + var count int + store.DB.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&count) + if count != 1 { + t.Errorf("expected 1 session, got %d", count) + } +} + +func TestSessionEnd(t *testing.T) { + store := testStore(t) + + // Start a session first. + _, err := SessionStart(store, "") + if err != nil { + t.Fatalf("SessionStart: %v", err) + } + + // End it with handoff notes. + if err := SessionEnd(store, "Left off at task 3"); err != nil { + t.Fatalf("SessionEnd: %v", err) + } + + // Verify ended_at and handoff_notes are set. + var endedAt, handoff string + err = store.DB.QueryRow( + "SELECT ended_at, handoff_notes FROM sessions LIMIT 1", + ).Scan(&endedAt, &handoff) + if err != nil { + t.Fatalf("query session: %v", err) + } + if endedAt == "" { + t.Error("ended_at should not be empty") + } + if handoff != "Left off at task 3" { + t.Errorf("handoff_notes = %q, want %q", handoff, "Left off at task 3") + } +} + +func TestSessionEnd_NoActiveSession(t *testing.T) { + store := testStore(t) + + err := SessionEnd(store, "notes") + if err == nil { + t.Error("expected error when no active session exists") + } +} + +func TestSessionHandoff(t *testing.T) { + store := testStore(t) + + // Start and end a session with handoff notes. + SessionStart(store, "") + SessionEnd(store, "Continue with task 5") + + // Start a new session -- should get previous handoff notes. + notes, err := SessionStart(store, "") + if err != nil { + t.Fatalf("SessionStart: %v", err) + } + if notes != "Continue with task 5" { + t.Errorf("handoff notes = %q, want %q", notes, "Continue with task 5") + } +} + +func TestSessionMultipleHandoffs(t *testing.T) { + store := testStore(t) + + // Session 1. + SessionStart(store, "") + SessionEnd(store, "First handoff") + + // Session 2. + SessionStart(store, "") + SessionEnd(store, "Second handoff") + + // Session 3 should get the most recent handoff. + notes, err := SessionStart(store, "") + if err != nil { + t.Fatalf("SessionStart: %v", err) + } + if notes != "Second handoff" { + t.Errorf("handoff notes = %q, want %q", notes, "Second handoff") + } +} diff --git a/internal/hive/start_ready_test.go b/internal/hive/start_ready_test.go new file mode 100644 index 0000000..a7516af --- /dev/null +++ b/internal/hive/start_ready_test.go @@ -0,0 +1,118 @@ +package hive + +import "testing" + +func TestStartCell(t *testing.T) { + store := testStore(t) + + cell, _ := CreateCell(store, CreateCellInput{Title: "Start me"}) + if err := StartCell(store, cell.ID); err != nil { + t.Fatalf("StartCell: %v", err) + } + + cells, _ := QueryCells(store, CellQuery{ID: cell.ID}) + if len(cells) != 1 { + t.Fatalf("expected 1 cell, got %d", len(cells)) + } + if cells[0].Status != "in_progress" { + t.Errorf("status = %q, want %q", cells[0].Status, "in_progress") + } +} + +func TestStartCell_NotFound(t *testing.T) { + store := testStore(t) + err := StartCell(store, "nonexistent") + if err == nil { + t.Error("expected error for nonexistent cell") + } +} + +func TestReadyCell_NoParent(t *testing.T) { + store := testStore(t) + + // Create two open cells with different priorities. + CreateCell(store, CreateCellInput{Title: "Low priority", Priority: 1}) + CreateCell(store, CreateCellInput{Title: "High priority", Priority: 3}) + + cell, err := ReadyCell(store) + if err != nil { + t.Fatalf("ReadyCell: %v", err) + } + if cell == nil { + t.Fatal("expected a cell, got nil") + } + if cell.Title != "High priority" { + t.Errorf("title = %q, want %q (highest priority)", cell.Title, "High priority") + } +} + +func TestReadyCell_Empty(t *testing.T) { + store := testStore(t) + + cell, err := ReadyCell(store) + if err != nil { + t.Fatalf("ReadyCell: %v", err) + } + if cell != nil { + t.Errorf("expected nil, got %+v", cell) + } +} + +func TestReadyCell_BlockedByParent(t *testing.T) { + store := testStore(t) + + // Create an epic (open) with a subtask. + epic, _, err := CreateEpic(store, CreateEpicInput{ + EpicTitle: "Blocking epic", + Subtasks: []SubtaskInput{{Title: "Blocked subtask", Priority: 3}}, + }) + if err != nil { + t.Fatalf("CreateEpic: %v", err) + } + + // The subtask should be blocked because its parent epic is open. + // Create a standalone cell with lower priority. + CreateCell(store, CreateCellInput{Title: "Standalone", Priority: 1}) + + cell, err := ReadyCell(store) + if err != nil { + t.Fatalf("ReadyCell: %v", err) + } + if cell == nil { + t.Fatal("expected a cell, got nil") + } + // Should return the standalone cell, not the blocked subtask. + if cell.Title != "Standalone" { + t.Errorf("title = %q, want %q", cell.Title, "Standalone") + } + + // Close the epic -- now the subtask should become ready. + CloseCell(store, epic.ID, "done") + + cell, err = ReadyCell(store) + if err != nil { + t.Fatalf("ReadyCell after close: %v", err) + } + if cell == nil { + t.Fatal("expected a cell after closing epic, got nil") + } + // Subtask has priority 3, standalone has priority 1. + if cell.Title != "Blocked subtask" { + t.Errorf("title = %q, want %q", cell.Title, "Blocked subtask") + } +} + +func TestReadyCell_AllClosed(t *testing.T) { + store := testStore(t) + + cell, _ := CreateCell(store, CreateCellInput{Title: "Close me"}) + CloseCell(store, cell.ID, "done") + + ready, err := ReadyCell(store) + if err != nil { + t.Fatalf("ReadyCell: %v", err) + } + if ready != nil { + t.Errorf("expected nil, got %+v", ready) + } +} diff --git a/internal/hive/sync.go b/internal/hive/sync.go new file mode 100644 index 0000000..2122df7 --- /dev/null +++ b/internal/hive/sync.go @@ -0,0 +1,52 @@ +package hive + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/unbound-force/replicator/internal/db" +) + +// Sync serializes all cells to JSON and commits them to git. +// +// Writes cells to /.hive/cells.json, then runs +// `git add .hive/ && git commit -m "hive sync"` in the project directory. +func Sync(store *db.Store, projectPath string) error { + cells, err := QueryCells(store, CellQuery{Limit: 10000}) + if err != nil { + return fmt.Errorf("query cells for sync: %w", err) + } + + hiveDir := filepath.Join(projectPath, ".hive") + if err := os.MkdirAll(hiveDir, 0o755); err != nil { + return fmt.Errorf("create .hive dir: %w", err) + } + + data, err := json.MarshalIndent(cells, "", " ") + if err != nil { + return fmt.Errorf("marshal cells: %w", err) + } + + cellsPath := filepath.Join(hiveDir, "cells.json") + if err := os.WriteFile(cellsPath, data, 0o644); err != nil { + return fmt.Errorf("write cells.json: %w", err) + } + + // Stage and commit the .hive directory. + addCmd := exec.Command("git", "add", ".hive/") + addCmd.Dir = projectPath + if out, err := addCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git add: %w\n%s", err, out) + } + + commitCmd := exec.Command("git", "commit", "-m", "hive sync", "--allow-empty") + commitCmd.Dir = projectPath + if out, err := commitCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git commit: %w\n%s", err, out) + } + + return nil +} diff --git a/internal/hive/sync_test.go b/internal/hive/sync_test.go new file mode 100644 index 0000000..be82ee9 --- /dev/null +++ b/internal/hive/sync_test.go @@ -0,0 +1,108 @@ +package hive + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestSync(t *testing.T) { + store := testStore(t) + dir := t.TempDir() + + // Initialize a git repo in the temp directory. + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + // Need an initial commit for git commit to work. + {"git", "commit", "--allow-empty", "-m", "init"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, out) + } + } + + // Create some cells. + CreateCell(store, CreateCellInput{Title: "Task A"}) + CreateCell(store, CreateCellInput{Title: "Task B", Type: "bug"}) + + // Run sync. + if err := Sync(store, dir); err != nil { + t.Fatalf("Sync: %v", err) + } + + // Verify cells.json was created. + cellsPath := filepath.Join(dir, ".hive", "cells.json") + data, err := os.ReadFile(cellsPath) + if err != nil { + t.Fatalf("read cells.json: %v", err) + } + + var cells []Cell + if err := json.Unmarshal(data, &cells); err != nil { + t.Fatalf("unmarshal cells.json: %v", err) + } + if len(cells) != 2 { + t.Errorf("expected 2 cells in JSON, got %d", len(cells)) + } + + // Verify git commit was made. + logCmd := exec.Command("git", "log", "--oneline", "-1") + logCmd.Dir = dir + out, err := logCmd.CombinedOutput() + if err != nil { + t.Fatalf("git log: %v\n%s", err, out) + } + if !contains(string(out), "hive sync") { + t.Errorf("git log = %q, want commit message containing 'hive sync'", string(out)) + } +} + +func TestSync_CreatesHiveDir(t *testing.T) { + store := testStore(t) + dir := t.TempDir() + + // Initialize git repo. + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + {"git", "commit", "--allow-empty", "-m", "init"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, out) + } + } + + // Sync with no cells -- should still create .hive/cells.json. + if err := Sync(store, dir); err != nil { + t.Fatalf("Sync: %v", err) + } + + hiveDir := filepath.Join(dir, ".hive") + if _, err := os.Stat(hiveDir); os.IsNotExist(err) { + t.Error(".hive directory was not created") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 50e081f..b559b1a 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -20,12 +20,14 @@ import ( // Server is an MCP JSON-RPC server. type Server struct { registry *registry.Registry + version string nextID atomic.Int64 } // NewServer creates an MCP server backed by the given tool registry. -func NewServer(reg *registry.Registry) *Server { - return &Server{registry: reg} +// The version string is reported in the initialize handshake. +func NewServer(reg *registry.Registry, version string) *Server { + return &Server{registry: reg, version: version} } // jsonrpcRequest is a JSON-RPC 2.0 request. @@ -134,7 +136,7 @@ func (s *Server) handleInitialize(req *jsonrpcRequest) *jsonrpcResponse { }, "serverInfo": map[string]any{ "name": "replicator", - "version": "0.1.0", + "version": s.version, }, }, } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 7a436c0..296bf26 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -21,7 +21,7 @@ func testServer(t *testing.T) (*Server, *db.Store) { reg := registry.New() hive.Register(reg, store) - return NewServer(reg), store + return NewServer(reg, "test"), store } func call(t *testing.T, s *Server, method string, params any) json.RawMessage { @@ -56,15 +56,19 @@ func TestToolsList(t *testing.T) { t.Fatalf("unmarshal: %v", err) } - if len(list.Tools) != 4 { - t.Errorf("expected 4 tools, got %d", len(list.Tools)) + if len(list.Tools) != 11 { + t.Errorf("expected 11 tools, got %d", len(list.Tools)) } names := make(map[string]bool) for _, tool := range list.Tools { names[tool.Name] = true } - for _, expected := range []string{"hive_cells", "hive_create", "hive_close", "hive_update"} { + for _, expected := range []string{ + "hive_cells", "hive_create", "hive_close", "hive_update", + "hive_create_epic", "hive_query", "hive_start", "hive_ready", + "hive_sync", "hive_session_start", "hive_session_end", + } { if !names[expected] { t.Errorf("missing tool: %s", expected) } diff --git a/internal/memory/deprecated.go b/internal/memory/deprecated.go new file mode 100644 index 0000000..a3da93e --- /dev/null +++ b/internal/memory/deprecated.go @@ -0,0 +1,44 @@ +package memory + +import ( + "encoding/json" + "fmt" +) + +// deprecationMap maps deprecated hivemind tool names to their Dewey replacements. +var deprecationMap = map[string]string{ + "hivemind_get": "dewey_get_page", + "hivemind_remove": "dewey_delete_page", + "hivemind_validate": "", // no direct equivalent + "hivemind_stats": "dewey_health", + "hivemind_index": "dewey_reload", + "hivemind_sync": "dewey_reload", +} + +// DeprecatedResponse returns a JSON string indicating the tool is deprecated. +// The response includes the tool name, a human-readable message, and the +// replacement tool (if one exists). +func DeprecatedResponse(toolName string) string { + replacement, ok := deprecationMap[toolName] + if !ok { + // Unknown tool -- still return a deprecation message. + replacement = "" + } + + msg := fmt.Sprintf("%s is deprecated.", toolName) + if replacement != "" { + msg += fmt.Sprintf(" Use %s instead.", replacement) + } else { + msg += " No direct replacement is available." + } + + resp := map[string]any{ + "deprecated": true, + "tool": toolName, + "message": msg, + "replacement": replacement, + } + + out, _ := json.MarshalIndent(resp, "", " ") + return string(out) +} diff --git a/internal/memory/deprecated_test.go b/internal/memory/deprecated_test.go new file mode 100644 index 0000000..e21ac61 --- /dev/null +++ b/internal/memory/deprecated_test.go @@ -0,0 +1,95 @@ +package memory + +import ( + "encoding/json" + "testing" +) + +func TestDeprecatedResponse_WithReplacement(t *testing.T) { + tests := []struct { + tool string + replacement string + }{ + {"hivemind_get", "dewey_get_page"}, + {"hivemind_remove", "dewey_delete_page"}, + {"hivemind_stats", "dewey_health"}, + {"hivemind_index", "dewey_reload"}, + {"hivemind_sync", "dewey_reload"}, + } + + for _, tt := range tests { + t.Run(tt.tool, func(t *testing.T) { + resp := DeprecatedResponse(tt.tool) + + var parsed map[string]any + if err := json.Unmarshal([]byte(resp), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if parsed["deprecated"] != true { + t.Error("deprecated should be true") + } + if parsed["tool"] != tt.tool { + t.Errorf("tool = %v, want %q", parsed["tool"], tt.tool) + } + if parsed["replacement"] != tt.replacement { + t.Errorf("replacement = %v, want %q", parsed["replacement"], tt.replacement) + } + }) + } +} + +func TestDeprecatedResponse_NoReplacement(t *testing.T) { + resp := DeprecatedResponse("hivemind_validate") + + var parsed map[string]any + if err := json.Unmarshal([]byte(resp), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if parsed["deprecated"] != true { + t.Error("deprecated should be true") + } + if parsed["replacement"] != "" { + t.Errorf("replacement = %v, want empty string", parsed["replacement"]) + } + + msg, ok := parsed["message"].(string) + if !ok { + t.Fatal("message should be a string") + } + if msg == "" { + t.Error("message should not be empty") + } +} + +func TestDeprecatedResponse_UnknownTool(t *testing.T) { + resp := DeprecatedResponse("hivemind_unknown") + + var parsed map[string]any + if err := json.Unmarshal([]byte(resp), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if parsed["deprecated"] != true { + t.Error("deprecated should be true") + } + if parsed["tool"] != "hivemind_unknown" { + t.Errorf("tool = %v, want %q", parsed["tool"], "hivemind_unknown") + } +} + +func TestDeprecatedResponse_ValidJSON(t *testing.T) { + // Verify all known deprecated tools produce valid JSON. + tools := []string{ + "hivemind_get", "hivemind_remove", "hivemind_validate", + "hivemind_stats", "hivemind_index", "hivemind_sync", + } + + for _, tool := range tools { + resp := DeprecatedResponse(tool) + if !json.Valid([]byte(resp)) { + t.Errorf("DeprecatedResponse(%q) produced invalid JSON: %s", tool, resp) + } + } +} diff --git a/internal/memory/proxy.go b/internal/memory/proxy.go new file mode 100644 index 0000000..bd04aed --- /dev/null +++ b/internal/memory/proxy.go @@ -0,0 +1,185 @@ +// Package memory provides a Dewey HTTP proxy client for semantic memory operations. +// +// The hivemind_store and hivemind_find tools proxy to Dewey's semantic search +// endpoints via JSON-RPC 2.0 over HTTP. Six secondary tools return deprecation +// messages pointing users to native Dewey tools. +// +// On connection failure, errors include a structured "DEWEY_UNAVAILABLE" code +// so agents can degrade gracefully. +package memory + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client is a Dewey HTTP proxy that forwards JSON-RPC calls. +type Client struct { + url string + http *http.Client +} + +// NewClient creates a Dewey proxy client with a 10-second timeout. +func NewClient(deweyURL string) *Client { + return &Client{ + url: deweyURL, + http: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// jsonRPCRequest is a JSON-RPC 2.0 request envelope. +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params any `json:"params"` + ID int `json:"id"` +} + +// jsonRPCResponse is a JSON-RPC 2.0 response envelope. +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` + ID int `json:"id"` +} + +// jsonRPCError is a JSON-RPC 2.0 error object. +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Call sends a JSON-RPC 2.0 POST to the Dewey endpoint. +// Returns the result field on success, or a structured error on failure. +func (c *Client) Call(method string, params any) (json.RawMessage, error) { + reqBody := jsonRPCRequest{ + JSONRPC: "2.0", + Method: method, + Params: params, + ID: 1, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + resp, err := c.http.Post(c.url, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, &UnavailableError{Cause: err} + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, &UnavailableError{ + Cause: fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)), + } + } + + var rpcResp jsonRPCResponse + if err := json.Unmarshal(respBody, &rpcResp); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + + if rpcResp.Error != nil { + return nil, fmt.Errorf("dewey error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message) + } + + return rpcResp.Result, nil +} + +// Health pings the Dewey endpoint to verify connectivity. +func (c *Client) Health() error { + _, err := c.Call("dewey_health", map[string]any{}) + return err +} + +// Store proxies to dewey_dewey_store_learning with a deprecation warning. +func (c *Client) Store(information, tags string) (map[string]any, error) { + params := map[string]any{ + "information": information, + } + if tags != "" { + params["tags"] = tags + } + + result, err := c.Call("dewey_dewey_store_learning", params) + if err != nil { + return nil, err + } + + var parsed map[string]any + if err := json.Unmarshal(result, &parsed); err != nil { + // If result isn't a map, wrap it. + parsed = map[string]any{"result": string(result)} + } + + // Add deprecation warning to response. + parsed["_warning"] = "hivemind_store is deprecated. Use dewey_dewey_store_learning directly." + + return parsed, nil +} + +// Find proxies to dewey_dewey_semantic_search with a deprecation warning. +func (c *Client) Find(query, collection string, limit int) (map[string]any, error) { + params := map[string]any{ + "query": query, + } + if limit > 0 { + params["limit"] = limit + } + if collection != "" { + // Collection maps to source_type filter in Dewey. + params["source_type"] = collection + } + + result, err := c.Call("dewey_dewey_semantic_search", params) + if err != nil { + return nil, err + } + + var parsed map[string]any + if err := json.Unmarshal(result, &parsed); err != nil { + parsed = map[string]any{"result": string(result)} + } + + // Add deprecation warning to response. + parsed["_warning"] = "hivemind_find is deprecated. Use dewey_dewey_semantic_search directly." + + return parsed, nil +} + +// UnavailableError indicates Dewey is not reachable. +type UnavailableError struct { + Cause error +} + +func (e *UnavailableError) Error() string { + return fmt.Sprintf("dewey unavailable: %v", e.Cause) +} + +func (e *UnavailableError) Unwrap() error { + return e.Cause +} + +// UnavailableResponse returns a structured JSON error for agents to parse. +func UnavailableResponse(err error) string { + resp := map[string]any{ + "error": err.Error(), + "code": "DEWEY_UNAVAILABLE", + "message": "Dewey semantic search is not available. Memory operations require a running Dewey instance.", + } + out, _ := json.MarshalIndent(resp, "", " ") + return string(out) +} diff --git a/internal/memory/proxy_test.go b/internal/memory/proxy_test.go new file mode 100644 index 0000000..cb60c65 --- /dev/null +++ b/internal/memory/proxy_test.go @@ -0,0 +1,319 @@ +package memory + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// newTestServer creates an httptest server that responds to JSON-RPC calls. +// The handler function receives the method and params and returns a result. +func newTestServer(t *testing.T, handler func(method string, params json.RawMessage) (any, *jsonRPCError)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + paramsBytes, _ := json.Marshal(req.Params) + result, rpcErr := handler(req.Method, paramsBytes) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + } + if rpcErr != nil { + resp.Error = rpcErr + } else { + resp.Result, _ = json.Marshal(result) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) +} + +func TestCall_Success(t *testing.T) { + srv := newTestServer(t, func(method string, params json.RawMessage) (any, *jsonRPCError) { + return map[string]string{"status": "ok"}, nil + }) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.Call("test_method", map[string]string{"key": "value"}) + if err != nil { + t.Fatalf("Call: %v", err) + } + + var parsed map[string]string + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if parsed["status"] != "ok" { + t.Errorf("status = %q, want %q", parsed["status"], "ok") + } +} + +func TestCall_RPCError(t *testing.T) { + srv := newTestServer(t, func(method string, params json.RawMessage) (any, *jsonRPCError) { + return nil, &jsonRPCError{Code: -32600, Message: "invalid request"} + }) + defer srv.Close() + + client := NewClient(srv.URL) + _, err := client.Call("test_method", nil) + if err == nil { + t.Fatal("expected error for RPC error response") + } + if got := err.Error(); got != "dewey error -32600: invalid request" { + t.Errorf("error = %q, want dewey error message", got) + } +} + +func TestCall_ConnectionRefused(t *testing.T) { + // Use a URL that will refuse connections. + client := NewClient("http://127.0.0.1:1") + _, err := client.Call("test_method", nil) + if err == nil { + t.Fatal("expected error for connection refused") + } + + var unavail *UnavailableError + if !errors.As(err, &unavail) { + t.Errorf("expected UnavailableError, got %T: %v", err, err) + } +} + +func TestCall_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + defer srv.Close() + + client := NewClient(srv.URL) + _, err := client.Call("test_method", nil) + if err == nil { + t.Fatal("expected error for HTTP 500") + } + + var unavail *UnavailableError + if !errors.As(err, &unavail) { + t.Errorf("expected UnavailableError, got %T: %v", err, err) + } +} + +func TestHealth_Success(t *testing.T) { + srv := newTestServer(t, func(method string, params json.RawMessage) (any, *jsonRPCError) { + if method != "dewey_health" { + t.Errorf("method = %q, want %q", method, "dewey_health") + } + return map[string]string{"status": "healthy"}, nil + }) + defer srv.Close() + + client := NewClient(srv.URL) + if err := client.Health(); err != nil { + t.Fatalf("Health: %v", err) + } +} + +func TestHealth_Failure(t *testing.T) { + client := NewClient("http://127.0.0.1:1") + err := client.Health() + if err == nil { + t.Fatal("expected error for unreachable Dewey") + } +} + +func TestStore_Success(t *testing.T) { + srv := newTestServer(t, func(method string, params json.RawMessage) (any, *jsonRPCError) { + if method != "dewey_dewey_store_learning" { + t.Errorf("method = %q, want %q", method, "dewey_dewey_store_learning") + } + + var p map[string]string + json.Unmarshal(params, &p) + if p["information"] != "test learning" { + t.Errorf("information = %q, want %q", p["information"], "test learning") + } + if p["tags"] != "go,testing" { + t.Errorf("tags = %q, want %q", p["tags"], "go,testing") + } + + return map[string]any{"id": "mem-123", "stored": true}, nil + }) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.Store("test learning", "go,testing") + if err != nil { + t.Fatalf("Store: %v", err) + } + + if result["_warning"] == nil { + t.Error("expected deprecation warning in response") + } + warning, ok := result["_warning"].(string) + if !ok || warning == "" { + t.Error("expected non-empty deprecation warning string") + } +} + +func TestStore_NoTags(t *testing.T) { + var receivedParams map[string]any + + srv := newTestServer(t, func(method string, params json.RawMessage) (any, *jsonRPCError) { + json.Unmarshal(params, &receivedParams) + return map[string]any{"stored": true}, nil + }) + defer srv.Close() + + client := NewClient(srv.URL) + _, err := client.Store("info only", "") + if err != nil { + t.Fatalf("Store: %v", err) + } + + if _, hasTags := receivedParams["tags"]; hasTags { + t.Error("tags should not be sent when empty") + } +} + +func TestStore_DeweyUnavailable(t *testing.T) { + client := NewClient("http://127.0.0.1:1") + _, err := client.Store("test", "") + if err == nil { + t.Fatal("expected error for unreachable Dewey") + } + + var unavail *UnavailableError + if !errors.As(err, &unavail) { + t.Errorf("expected UnavailableError, got %T", err) + } +} + +func TestFind_Success(t *testing.T) { + srv := newTestServer(t, func(method string, params json.RawMessage) (any, *jsonRPCError) { + if method != "dewey_dewey_semantic_search" { + t.Errorf("method = %q, want %q", method, "dewey_dewey_semantic_search") + } + + var p map[string]any + json.Unmarshal(params, &p) + if p["query"] != "test query" { + t.Errorf("query = %v, want %q", p["query"], "test query") + } + + return map[string]any{ + "results": []map[string]string{ + {"page": "test-page", "score": "0.95"}, + }, + }, nil + }) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.Find("test query", "", 5) + if err != nil { + t.Fatalf("Find: %v", err) + } + + if result["_warning"] == nil { + t.Error("expected deprecation warning in response") + } +} + +func TestFind_WithCollection(t *testing.T) { + var receivedParams map[string]any + + srv := newTestServer(t, func(method string, params json.RawMessage) (any, *jsonRPCError) { + json.Unmarshal(params, &receivedParams) + return map[string]any{"results": []any{}}, nil + }) + defer srv.Close() + + client := NewClient(srv.URL) + _, err := client.Find("query", "learnings", 10) + if err != nil { + t.Fatalf("Find: %v", err) + } + + if receivedParams["source_type"] != "learnings" { + t.Errorf("source_type = %v, want %q", receivedParams["source_type"], "learnings") + } +} + +func TestFind_WithLimit(t *testing.T) { + var receivedParams map[string]any + + srv := newTestServer(t, func(method string, params json.RawMessage) (any, *jsonRPCError) { + json.Unmarshal(params, &receivedParams) + return map[string]any{"results": []any{}}, nil + }) + defer srv.Close() + + client := NewClient(srv.URL) + _, err := client.Find("query", "", 7) + if err != nil { + t.Fatalf("Find: %v", err) + } + + // JSON numbers unmarshal as float64. + if receivedParams["limit"] != float64(7) { + t.Errorf("limit = %v, want 7", receivedParams["limit"]) + } +} + +func TestFind_ZeroLimit(t *testing.T) { + var receivedParams map[string]any + + srv := newTestServer(t, func(method string, params json.RawMessage) (any, *jsonRPCError) { + json.Unmarshal(params, &receivedParams) + return map[string]any{"results": []any{}}, nil + }) + defer srv.Close() + + client := NewClient(srv.URL) + _, err := client.Find("query", "", 0) + if err != nil { + t.Fatalf("Find: %v", err) + } + + if _, hasLimit := receivedParams["limit"]; hasLimit { + t.Error("limit should not be sent when zero") + } +} + +func TestFind_DeweyUnavailable(t *testing.T) { + client := NewClient("http://127.0.0.1:1") + _, err := client.Find("test", "", 5) + if err == nil { + t.Fatal("expected error for unreachable Dewey") + } +} + +func TestUnavailableResponse(t *testing.T) { + err := &UnavailableError{Cause: errors.New("connection refused")} + resp := UnavailableResponse(err) + + var parsed map[string]any + if jsonErr := json.Unmarshal([]byte(resp), &parsed); jsonErr != nil { + t.Fatalf("unmarshal: %v", jsonErr) + } + + if parsed["code"] != "DEWEY_UNAVAILABLE" { + t.Errorf("code = %v, want %q", parsed["code"], "DEWEY_UNAVAILABLE") + } +} + +func TestNewClient_Timeout(t *testing.T) { + client := NewClient("http://example.com") + if client.http.Timeout != 10*time.Second { + t.Errorf("timeout = %v, want 10s", client.http.Timeout) + } +} diff --git a/internal/query/presets.go b/internal/query/presets.go new file mode 100644 index 0000000..fdaf1fd --- /dev/null +++ b/internal/query/presets.go @@ -0,0 +1,157 @@ +// Package query provides preset database queries for the replicator CLI. +// +// Each preset is a named SQL query that produces a human-readable table. +// Presets cover common observability needs: agent activity, cell status, +// swarm completion rates, and recent events. +package query + +import ( + "fmt" + "io" + + "github.com/unbound-force/replicator/internal/db" +) + +// Preset names. +const ( + AgentActivity24h = "agent_activity_24h" + CellsByStatus = "cells_by_status" + SwarmCompletionRate = "swarm_completion_rate" + RecentEvents = "recent_events" +) + +// ListPresets returns all available preset names. +func ListPresets() []string { + return []string{ + AgentActivity24h, + CellsByStatus, + SwarmCompletionRate, + RecentEvents, + } +} + +// Run executes a preset query and writes the results to w. +func Run(store *db.Store, presetName string, w io.Writer) error { + switch presetName { + case AgentActivity24h: + return runAgentActivity(store, w) + case CellsByStatus: + return runCellsByStatus(store, w) + case SwarmCompletionRate: + return runSwarmCompletionRate(store, w) + case RecentEvents: + return runRecentEvents(store, w) + default: + return fmt.Errorf("unknown preset: %q (use --list to see available presets)", presetName) + } +} + +func runAgentActivity(store *db.Store, w io.Writer) error { + rows, err := store.DB.Query(` + SELECT COALESCE(agent_name, '(unknown)') as agent, COUNT(*) as events + FROM events + WHERE created_at >= datetime('now', '-24 hours') + GROUP BY agent_name + ORDER BY events DESC + LIMIT 20`) + if err != nil { + return fmt.Errorf("query agent activity: %w", err) + } + defer rows.Close() + + fmt.Fprintf(w, "%-30s %s\n", "AGENT", "EVENTS (24h)") + fmt.Fprintf(w, "%-30s %s\n", "-----", "------------") + + count := 0 + for rows.Next() { + var agent string + var events int + if err := rows.Scan(&agent, &events); err != nil { + return err + } + fmt.Fprintf(w, "%-30s %d\n", agent, events) + count++ + } + if count == 0 { + fmt.Fprintln(w, "(no activity in last 24 hours)") + } + return rows.Err() +} + +func runCellsByStatus(store *db.Store, w io.Writer) error { + rows, err := store.DB.Query(` + SELECT status, type, COUNT(*) as count + FROM beads + GROUP BY status, type + ORDER BY status, type`) + if err != nil { + return fmt.Errorf("query cells by status: %w", err) + } + defer rows.Close() + + fmt.Fprintf(w, "%-15s %-10s %s\n", "STATUS", "TYPE", "COUNT") + fmt.Fprintf(w, "%-15s %-10s %s\n", "------", "----", "-----") + + count := 0 + for rows.Next() { + var status, cellType string + var n int + if err := rows.Scan(&status, &cellType, &n); err != nil { + return err + } + fmt.Fprintf(w, "%-15s %-10s %d\n", status, cellType, n) + count++ + } + if count == 0 { + fmt.Fprintln(w, "(no cells)") + } + return rows.Err() +} + +func runSwarmCompletionRate(store *db.Store, w io.Writer) error { + // Count completed vs total swarm events. + var total, completed int + store.DB.QueryRow(`SELECT COUNT(*) FROM events WHERE type LIKE 'swarm_%'`).Scan(&total) + store.DB.QueryRow(`SELECT COUNT(*) FROM events WHERE type = 'swarm_complete'`).Scan(&completed) + + fmt.Fprintln(w, "Swarm Completion Rate:") + fmt.Fprintf(w, " Total swarm events: %d\n", total) + fmt.Fprintf(w, " Completed: %d\n", completed) + if total > 0 { + rate := float64(completed) / float64(total) * 100 + fmt.Fprintf(w, " Completion rate: %.1f%%\n", rate) + } else { + fmt.Fprintln(w, " Completion rate: N/A (no swarm events)") + } + return nil +} + +func runRecentEvents(store *db.Store, w io.Writer) error { + rows, err := store.DB.Query(` + SELECT id, type, project_key, created_at + FROM events + ORDER BY created_at DESC + LIMIT 20`) + if err != nil { + return fmt.Errorf("query recent events: %w", err) + } + defer rows.Close() + + fmt.Fprintf(w, "%-6s %-25s %-20s %s\n", "ID", "TYPE", "PROJECT", "CREATED") + fmt.Fprintf(w, "%-6s %-25s %-20s %s\n", "--", "----", "-------", "-------") + + count := 0 + for rows.Next() { + var id int + var eventType, projectKey, createdAt string + if err := rows.Scan(&id, &eventType, &projectKey, &createdAt); err != nil { + return err + } + fmt.Fprintf(w, "%-6d %-25s %-20s %s\n", id, eventType, projectKey, createdAt) + count++ + } + if count == 0 { + fmt.Fprintln(w, "(no events)") + } + return rows.Err() +} diff --git a/internal/query/presets_test.go b/internal/query/presets_test.go new file mode 100644 index 0000000..d9b7753 --- /dev/null +++ b/internal/query/presets_test.go @@ -0,0 +1,224 @@ +package query + +import ( + "bytes" + "strings" + "testing" + + "github.com/unbound-force/replicator/internal/db" +) + +func testStore(t *testing.T) *db.Store { + t.Helper() + store, err := db.OpenMemory() + if err != nil { + t.Fatalf("OpenMemory: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +func TestListPresets(t *testing.T) { + presets := ListPresets() + if len(presets) != 4 { + t.Fatalf("expected 4 presets, got %d", len(presets)) + } + + expected := map[string]bool{ + AgentActivity24h: true, + CellsByStatus: true, + SwarmCompletionRate: true, + RecentEvents: true, + } + for _, p := range presets { + if !expected[p] { + t.Errorf("unexpected preset: %q", p) + } + } +} + +func TestRun_UnknownPreset(t *testing.T) { + store := testStore(t) + var buf bytes.Buffer + + err := Run(store, "nonexistent", &buf) + if err == nil { + t.Fatal("expected error for unknown preset") + } + if !strings.Contains(err.Error(), "unknown preset") { + t.Errorf("error = %q, want 'unknown preset'", err.Error()) + } +} + +func TestRun_AgentActivity_Empty(t *testing.T) { + store := testStore(t) + var buf bytes.Buffer + + err := Run(store, AgentActivity24h, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "AGENT") { + t.Error("expected AGENT header") + } + if !strings.Contains(output, "(no activity") { + t.Error("expected empty message") + } +} + +func TestRun_AgentActivity_WithData(t *testing.T) { + store := testStore(t) + + // Insert events with agent_name in payload. + store.DB.Exec(`INSERT INTO events (type, payload, project_key) VALUES ('swarm_init', '{"agent_name": "worker-1"}', 'test')`) + store.DB.Exec(`INSERT INTO events (type, payload, project_key) VALUES ('swarm_init', '{"agent_name": "worker-1"}', 'test')`) + store.DB.Exec(`INSERT INTO events (type, payload, project_key) VALUES ('swarm_init', '{"agent_name": "worker-2"}', 'test')`) + + var buf bytes.Buffer + err := Run(store, AgentActivity24h, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "worker-1") { + t.Error("expected worker-1 in output") + } +} + +func TestRun_CellsByStatus_Empty(t *testing.T) { + store := testStore(t) + var buf bytes.Buffer + + err := Run(store, CellsByStatus, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "STATUS") { + t.Error("expected STATUS header") + } + if !strings.Contains(output, "(no cells)") { + t.Error("expected empty message") + } +} + +func TestRun_CellsByStatus_WithData(t *testing.T) { + store := testStore(t) + + store.DB.Exec(`INSERT INTO beads (id, title, status, type) VALUES ('c1', 'Task 1', 'open', 'task')`) + store.DB.Exec(`INSERT INTO beads (id, title, status, type) VALUES ('c2', 'Task 2', 'closed', 'task')`) + store.DB.Exec(`INSERT INTO beads (id, title, status, type) VALUES ('c3', 'Bug 1', 'open', 'bug')`) + + var buf bytes.Buffer + err := Run(store, CellsByStatus, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "open") { + t.Error("expected 'open' in output") + } + if !strings.Contains(output, "closed") { + t.Error("expected 'closed' in output") + } +} + +func TestRun_SwarmCompletionRate_Empty(t *testing.T) { + store := testStore(t) + var buf bytes.Buffer + + err := Run(store, SwarmCompletionRate, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Swarm Completion Rate") { + t.Error("expected header") + } + if !strings.Contains(output, "N/A") { + t.Error("expected N/A for empty data") + } +} + +func TestRun_SwarmCompletionRate_WithData(t *testing.T) { + store := testStore(t) + + store.DB.Exec(`INSERT INTO events (type, payload, project_key) VALUES ('swarm_init', '{}', 'test')`) + store.DB.Exec(`INSERT INTO events (type, payload, project_key) VALUES ('swarm_progress', '{}', 'test')`) + store.DB.Exec(`INSERT INTO events (type, payload, project_key) VALUES ('swarm_complete', '{}', 'test')`) + + var buf bytes.Buffer + err := Run(store, SwarmCompletionRate, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Total swarm events") { + t.Error("expected total count") + } + if !strings.Contains(output, "Completed") { + t.Error("expected completed count") + } +} + +func TestRun_RecentEvents_Empty(t *testing.T) { + store := testStore(t) + var buf bytes.Buffer + + err := Run(store, RecentEvents, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "ID") { + t.Error("expected ID header") + } + if !strings.Contains(output, "(no events)") { + t.Error("expected empty message") + } +} + +func TestRun_RecentEvents_WithData(t *testing.T) { + store := testStore(t) + + store.DB.Exec(`INSERT INTO events (type, payload, project_key) VALUES ('test_event', '{}', 'my-project')`) + + var buf bytes.Buffer + err := Run(store, RecentEvents, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "test_event") { + t.Error("expected test_event in output") + } + if !strings.Contains(output, "my-project") { + t.Error("expected my-project in output") + } +} + +func TestRun_AllPresets(t *testing.T) { + store := testStore(t) + + // Verify all presets run without error on an empty database. + for _, preset := range ListPresets() { + t.Run(preset, func(t *testing.T) { + var buf bytes.Buffer + if err := Run(store, preset, &buf); err != nil { + t.Fatalf("Run(%q): %v", preset, err) + } + if buf.Len() == 0 { + t.Errorf("preset %q produced no output", preset) + } + }) + } +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..a8909ef --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,124 @@ +// Package stats provides database statistics for the replicator CLI. +// +// Queries the events and cells tables to produce a human-readable summary +// of system activity and work item status. +package stats + +import ( + "fmt" + "io" + + "github.com/unbound-force/replicator/internal/db" +) + +// eventCount holds a type and its count from the events table. +type eventCount struct { + Type string + Count int +} + +// Run queries the database for statistics and writes a formatted report. +func Run(store *db.Store, w io.Writer) error { + // Events by type. + eventCounts, err := queryEventCounts(store) + if err != nil { + return fmt.Errorf("query event counts: %w", err) + } + + // Recent events (last 24h). + recentCount, err := queryRecentEvents(store) + if err != nil { + return fmt.Errorf("query recent events: %w", err) + } + + // Cells by status. + cellCounts, err := queryCellCounts(store) + if err != nil { + return fmt.Errorf("query cell counts: %w", err) + } + + // Total cells. + totalCells := 0 + for _, c := range cellCounts { + totalCells += c.Count + } + + // Print report. + fmt.Fprintln(w, "=== Replicator Stats ===") + fmt.Fprintln(w) + + fmt.Fprintln(w, "Events by Type:") + if len(eventCounts) == 0 { + fmt.Fprintln(w, " (no events)") + } + for _, ec := range eventCounts { + fmt.Fprintf(w, " %-30s %d\n", ec.Type, ec.Count) + } + fmt.Fprintln(w) + + fmt.Fprintf(w, "Recent Activity (24h): %d events\n", recentCount) + fmt.Fprintln(w) + + fmt.Fprintf(w, "Cells (%d total):\n", totalCells) + if len(cellCounts) == 0 { + fmt.Fprintln(w, " (no cells)") + } + for _, cc := range cellCounts { + fmt.Fprintf(w, " %-15s %d\n", cc.Type, cc.Count) + } + + return nil +} + +func queryEventCounts(store *db.Store) ([]eventCount, error) { + rows, err := store.DB.Query(` + SELECT type, COUNT(*) as count + FROM events + GROUP BY type + ORDER BY count DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var counts []eventCount + for rows.Next() { + var ec eventCount + if err := rows.Scan(&ec.Type, &ec.Count); err != nil { + return nil, err + } + counts = append(counts, ec) + } + return counts, rows.Err() +} + +func queryRecentEvents(store *db.Store) (int, error) { + var count int + err := store.DB.QueryRow(` + SELECT COUNT(*) + FROM events + WHERE created_at >= datetime('now', '-24 hours')`).Scan(&count) + return count, err +} + +func queryCellCounts(store *db.Store) ([]eventCount, error) { + rows, err := store.DB.Query(` + SELECT status, COUNT(*) as count + FROM beads + GROUP BY status + ORDER BY count DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var counts []eventCount + for rows.Next() { + var ec eventCount + if err := rows.Scan(&ec.Type, &ec.Count); err != nil { + return nil, err + } + counts = append(counts, ec) + } + return counts, rows.Err() +} diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go new file mode 100644 index 0000000..4c7be6f --- /dev/null +++ b/internal/stats/stats_test.go @@ -0,0 +1,127 @@ +package stats + +import ( + "bytes" + "strings" + "testing" + + "github.com/unbound-force/replicator/internal/db" +) + +func testStore(t *testing.T) *db.Store { + t.Helper() + store, err := db.OpenMemory() + if err != nil { + t.Fatalf("OpenMemory: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +func TestRun_EmptyDatabase(t *testing.T) { + store := testStore(t) + var buf bytes.Buffer + + err := Run(store, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Replicator Stats") { + t.Error("expected header in output") + } + if !strings.Contains(output, "(no events)") { + t.Error("expected '(no events)' for empty database") + } + if !strings.Contains(output, "(no cells)") { + t.Error("expected '(no cells)' for empty database") + } + if !strings.Contains(output, "Recent Activity (24h): 0 events") { + t.Error("expected 0 recent events") + } +} + +func TestRun_WithEvents(t *testing.T) { + store := testStore(t) + + // Insert some events. + for i := 0; i < 3; i++ { + store.DB.Exec(`INSERT INTO events (type, payload, project_key) VALUES (?, '{}', 'test')`, "swarm_init") + } + store.DB.Exec(`INSERT INTO events (type, payload, project_key) VALUES (?, '{}', 'test')`, "swarm_complete") + + var buf bytes.Buffer + err := Run(store, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "swarm_init") { + t.Error("expected swarm_init in output") + } + if !strings.Contains(output, "swarm_complete") { + t.Error("expected swarm_complete in output") + } +} + +func TestRun_WithCells(t *testing.T) { + store := testStore(t) + + // Insert cells with different statuses. + store.DB.Exec(`INSERT INTO beads (id, title, status) VALUES ('c1', 'Task 1', 'open')`) + store.DB.Exec(`INSERT INTO beads (id, title, status) VALUES ('c2', 'Task 2', 'open')`) + store.DB.Exec(`INSERT INTO beads (id, title, status) VALUES ('c3', 'Task 3', 'closed')`) + + var buf bytes.Buffer + err := Run(store, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "3 total") { + t.Errorf("expected '3 total' in output, got:\n%s", output) + } + if !strings.Contains(output, "open") { + t.Error("expected 'open' status in output") + } + if !strings.Contains(output, "closed") { + t.Error("expected 'closed' status in output") + } +} + +func TestRun_RecentActivity(t *testing.T) { + store := testStore(t) + + // Insert events with current timestamp (default). + for i := 0; i < 5; i++ { + store.DB.Exec(`INSERT INTO events (type, payload, project_key) VALUES ('test', '{}', 'test')`) + } + + var buf bytes.Buffer + err := Run(store, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Recent Activity (24h): 5 events") { + t.Errorf("expected 5 recent events, got:\n%s", output) + } +} + +func TestRun_WritesToWriter(t *testing.T) { + store := testStore(t) + var buf bytes.Buffer + + err := Run(store, &buf) + if err != nil { + t.Fatalf("Run: %v", err) + } + + if buf.Len() == 0 { + t.Error("expected non-empty output") + } +} diff --git a/internal/swarm/decompose.go b/internal/swarm/decompose.go new file mode 100644 index 0000000..10e6452 --- /dev/null +++ b/internal/swarm/decompose.go @@ -0,0 +1,196 @@ +package swarm + +import ( + "encoding/json" + "fmt" + "strings" +) + +// SelectStrategy recommends a decomposition strategy based on task description keywords. +// Returns one of: "file-based", "feature-based", "risk-based". +func SelectStrategy(task, context string) (string, error) { + if task == "" { + return "", fmt.Errorf("task description is required") + } + + lower := strings.ToLower(task + " " + context) + + // Risk-based: security, migration, breaking changes, performance. + riskKeywords := []string{"security", "migration", "breaking", "performance", "critical", "vulnerability", "upgrade"} + for _, kw := range riskKeywords { + if strings.Contains(lower, kw) { + return "risk-based", nil + } + } + + // File-based: refactor, rename, move, reorganize, restructure. + fileKeywords := []string{"refactor", "rename", "move", "reorganize", "restructure", "file", "directory"} + for _, kw := range fileKeywords { + if strings.Contains(lower, kw) { + return "file-based", nil + } + } + + // Default: feature-based for new features, enhancements, etc. + return "feature-based", nil +} + +// PlanPrompt generates a structured prompt for task planning with a given strategy. +// This is a prompt generator -- it does NOT call an LLM. +func PlanPrompt(task, strategy, context string, maxSubtasks int) string { + if maxSubtasks <= 0 { + maxSubtasks = 5 + } + if strategy == "" { + strategy = "feature-based" + } + + var sb strings.Builder + sb.WriteString("## Task Decomposition Plan\n\n") + sb.WriteString(fmt.Sprintf("**Strategy:** %s\n", strategy)) + sb.WriteString(fmt.Sprintf("**Max Subtasks:** %d\n\n", maxSubtasks)) + sb.WriteString(fmt.Sprintf("**Task:** %s\n\n", task)) + + if context != "" { + sb.WriteString(fmt.Sprintf("**Context:**\n%s\n\n", context)) + } + + sb.WriteString("### Instructions\n\n") + + switch strategy { + case "file-based": + sb.WriteString("Decompose this task by file boundaries. Each subtask should modify a distinct set of files.\n") + sb.WriteString("Group related file changes together. Minimize cross-file dependencies between subtasks.\n") + case "risk-based": + sb.WriteString("Decompose this task by risk level. Order subtasks from lowest to highest risk.\n") + sb.WriteString("Isolate risky changes so they can be reviewed independently.\n") + default: + sb.WriteString("Decompose this task by feature/functionality. Each subtask delivers a coherent piece of functionality.\n") + sb.WriteString("Ensure subtasks can be tested independently.\n") + } + + sb.WriteString("\n### Output Format\n\n") + sb.WriteString("Return a JSON object with this structure:\n") + sb.WriteString("```json\n") + sb.WriteString(`{ + "epic_title": "string", + "subtasks": [ + { + "title": "string", + "files": ["path/to/file.go"], + "priority": 1 + } + ] +}`) + sb.WriteString("\n```\n") + + return sb.String() +} + +// Decompose generates a decomposition prompt for breaking a task into subtasks. +// This is a prompt generator -- it does NOT call an LLM. +func Decompose(task, context string, maxSubtasks int) string { + if maxSubtasks <= 0 { + maxSubtasks = 5 + } + + var sb strings.Builder + sb.WriteString("## Task Decomposition\n\n") + sb.WriteString(fmt.Sprintf("Break the following task into at most %d independent subtasks.\n\n", maxSubtasks)) + sb.WriteString(fmt.Sprintf("**Task:** %s\n\n", task)) + + if context != "" { + sb.WriteString(fmt.Sprintf("**Context:**\n%s\n\n", context)) + } + + sb.WriteString("### Requirements\n\n") + sb.WriteString("1. Each subtask should be independently executable\n") + sb.WriteString("2. List the files each subtask will modify\n") + sb.WriteString("3. Identify dependencies between subtasks\n") + sb.WriteString("4. Assign priority (0=highest, 3=lowest)\n\n") + + sb.WriteString("### Output Format\n\n") + sb.WriteString("Return a JSON object with this structure:\n") + sb.WriteString("```json\n") + sb.WriteString(`{ + "epic_title": "string", + "subtasks": [ + { + "title": "string", + "files": ["path/to/file.go"], + "priority": 1 + } + ] +}`) + sb.WriteString("\n```\n") + + return sb.String() +} + +// ValidateDecomposition validates a JSON decomposition response against the +// expected structure. Returns the parsed structure or an error describing +// what's missing. +func ValidateDecomposition(response string) (map[string]any, error) { + if response == "" { + return nil, fmt.Errorf("empty response") + } + + // Try to extract JSON from markdown code blocks. + cleaned := extractJSON(response) + + var result map[string]any + if err := json.Unmarshal([]byte(cleaned), &result); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + + // Validate required fields. + if _, ok := result["epic_title"]; !ok { + return nil, fmt.Errorf("missing required field: epic_title") + } + + subtasks, ok := result["subtasks"] + if !ok { + return nil, fmt.Errorf("missing required field: subtasks") + } + + subtaskList, ok := subtasks.([]any) + if !ok { + return nil, fmt.Errorf("subtasks must be an array") + } + + if len(subtaskList) == 0 { + return nil, fmt.Errorf("subtasks array is empty") + } + + // Validate each subtask has a title. + for i, st := range subtaskList { + stMap, ok := st.(map[string]any) + if !ok { + return nil, fmt.Errorf("subtask %d is not an object", i) + } + if _, ok := stMap["title"]; !ok { + return nil, fmt.Errorf("subtask %d missing required field: title", i) + } + } + + return result, nil +} + +// extractJSON attempts to extract JSON from a markdown code block. +func extractJSON(s string) string { + // Look for ```json ... ``` blocks. + if idx := strings.Index(s, "```json"); idx >= 0 { + start := idx + len("```json") + if end := strings.Index(s[start:], "```"); end >= 0 { + return strings.TrimSpace(s[start : start+end]) + } + } + // Look for ``` ... ``` blocks. + if idx := strings.Index(s, "```"); idx >= 0 { + start := idx + len("```") + if end := strings.Index(s[start:], "```"); end >= 0 { + return strings.TrimSpace(s[start : start+end]) + } + } + return strings.TrimSpace(s) +} diff --git a/internal/swarm/decompose_test.go b/internal/swarm/decompose_test.go new file mode 100644 index 0000000..cf84244 --- /dev/null +++ b/internal/swarm/decompose_test.go @@ -0,0 +1,175 @@ +package swarm + +import ( + "strings" + "testing" +) + +func TestSelectStrategy_RiskBased(t *testing.T) { + tests := []string{"Fix security vulnerability", "Database migration", "Breaking API change"} + for _, task := range tests { + strategy, err := SelectStrategy(task, "") + if err != nil { + t.Fatalf("SelectStrategy(%q): %v", task, err) + } + if strategy != "risk-based" { + t.Errorf("SelectStrategy(%q) = %q, want %q", task, strategy, "risk-based") + } + } +} + +func TestSelectStrategy_FileBased(t *testing.T) { + tests := []string{"Refactor the auth module", "Rename package", "Move files to new directory"} + for _, task := range tests { + strategy, err := SelectStrategy(task, "") + if err != nil { + t.Fatalf("SelectStrategy(%q): %v", task, err) + } + if strategy != "file-based" { + t.Errorf("SelectStrategy(%q) = %q, want %q", task, strategy, "file-based") + } + } +} + +func TestSelectStrategy_FeatureBased(t *testing.T) { + strategy, err := SelectStrategy("Add user authentication", "") + if err != nil { + t.Fatalf("SelectStrategy: %v", err) + } + if strategy != "feature-based" { + t.Errorf("strategy = %q, want %q", strategy, "feature-based") + } +} + +func TestSelectStrategy_Empty(t *testing.T) { + _, err := SelectStrategy("", "") + if err == nil { + t.Error("expected error for empty task") + } +} + +func TestSelectStrategy_ContextInfluence(t *testing.T) { + // Context can also trigger strategy selection. + strategy, err := SelectStrategy("Update the API", "this is a critical security fix") + if err != nil { + t.Fatalf("SelectStrategy: %v", err) + } + if strategy != "risk-based" { + t.Errorf("strategy = %q, want %q", strategy, "risk-based") + } +} + +func TestPlanPrompt(t *testing.T) { + prompt := PlanPrompt("Build auth system", "file-based", "Go project", 3) + + if !strings.Contains(prompt, "file-based") { + t.Error("prompt should mention strategy") + } + if !strings.Contains(prompt, "Build auth system") { + t.Error("prompt should contain task") + } + if !strings.Contains(prompt, "Go project") { + t.Error("prompt should contain context") + } + if !strings.Contains(prompt, "3") { + t.Error("prompt should mention max subtasks") + } +} + +func TestPlanPrompt_Defaults(t *testing.T) { + prompt := PlanPrompt("task", "", "", 0) + if !strings.Contains(prompt, "feature-based") { + t.Error("default strategy should be feature-based") + } + if !strings.Contains(prompt, "5") { + t.Error("default max subtasks should be 5") + } +} + +func TestDecompose(t *testing.T) { + prompt := Decompose("Build auth system", "Go project", 4) + + if !strings.Contains(prompt, "Build auth system") { + t.Error("prompt should contain task") + } + if !strings.Contains(prompt, "4") { + t.Error("prompt should mention max subtasks") + } + if !strings.Contains(prompt, "epic_title") { + t.Error("prompt should include output format") + } +} + +func TestValidateDecomposition_Valid(t *testing.T) { + input := `{ + "epic_title": "Auth System", + "subtasks": [ + {"title": "Add login", "files": ["auth.go"], "priority": 1} + ] + }` + + result, err := ValidateDecomposition(input) + if err != nil { + t.Fatalf("ValidateDecomposition: %v", err) + } + if result["epic_title"] != "Auth System" { + t.Errorf("epic_title = %v, want %q", result["epic_title"], "Auth System") + } +} + +func TestValidateDecomposition_FromMarkdown(t *testing.T) { + input := "Here is the plan:\n```json\n" + `{ + "epic_title": "Auth", + "subtasks": [{"title": "Login"}] + }` + "\n```\n" + + result, err := ValidateDecomposition(input) + if err != nil { + t.Fatalf("ValidateDecomposition: %v", err) + } + if result["epic_title"] != "Auth" { + t.Errorf("epic_title = %v, want %q", result["epic_title"], "Auth") + } +} + +func TestValidateDecomposition_Empty(t *testing.T) { + _, err := ValidateDecomposition("") + if err == nil { + t.Error("expected error for empty response") + } +} + +func TestValidateDecomposition_MissingEpicTitle(t *testing.T) { + _, err := ValidateDecomposition(`{"subtasks": [{"title": "a"}]}`) + if err == nil { + t.Error("expected error for missing epic_title") + } +} + +func TestValidateDecomposition_MissingSubtasks(t *testing.T) { + _, err := ValidateDecomposition(`{"epic_title": "test"}`) + if err == nil { + t.Error("expected error for missing subtasks") + } +} + +func TestValidateDecomposition_EmptySubtasks(t *testing.T) { + _, err := ValidateDecomposition(`{"epic_title": "test", "subtasks": []}`) + if err == nil { + t.Error("expected error for empty subtasks") + } +} + +func TestValidateDecomposition_SubtaskMissingTitle(t *testing.T) { + _, err := ValidateDecomposition(`{"epic_title": "test", "subtasks": [{"files": ["a.go"]}]}`) + if err == nil { + t.Error("expected error for subtask missing title") + } +} + +func TestValidateDecomposition_InvalidJSON(t *testing.T) { + _, err := ValidateDecomposition("not json at all") + if err == nil { + t.Error("expected error for invalid JSON") + } +} diff --git a/internal/swarm/init.go b/internal/swarm/init.go new file mode 100644 index 0000000..ad68b61 --- /dev/null +++ b/internal/swarm/init.go @@ -0,0 +1,50 @@ +// Package swarm implements multi-agent coordination for parallel task execution. +// +// The swarm package provides session initialization, task decomposition (prompt +// generation), subtask spawning, progress tracking, worktree management, code +// review prompts, and historical insights. Most functions are either prompt +// generators (returning strings) or event recorders (writing to the events table). +package swarm + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/unbound-force/replicator/internal/db" +) + +// Init initializes a swarm session by recording an event and returning session info. +// The isolation parameter specifies the isolation strategy ("worktree" or "reservation"). +func Init(store *db.Store, projectPath, isolation string) (map[string]any, error) { + if projectPath == "" { + return nil, fmt.Errorf("project_path is required") + } + if isolation == "" { + isolation = "reservation" + } + + payload := map[string]string{ + "project_path": projectPath, + "isolation": isolation, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + _, err = store.DB.Exec( + "INSERT INTO events (type, payload, project_key) VALUES (?, ?, ?)", + "swarm_init", string(payloadJSON), projectPath, + ) + if err != nil { + return nil, fmt.Errorf("record swarm_init event: %w", err) + } + + return map[string]any{ + "status": "initialized", + "project_path": projectPath, + "isolation": isolation, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil +} diff --git a/internal/swarm/init_test.go b/internal/swarm/init_test.go new file mode 100644 index 0000000..e799e2f --- /dev/null +++ b/internal/swarm/init_test.go @@ -0,0 +1,68 @@ +package swarm + +import ( + "testing" + + "github.com/unbound-force/replicator/internal/db" +) + +func testStore(t *testing.T) *db.Store { + t.Helper() + store, err := db.OpenMemory() + if err != nil { + t.Fatalf("OpenMemory: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +func TestInit(t *testing.T) { + store := testStore(t) + + result, err := Init(store, "/tmp/project", "worktree") + if err != nil { + t.Fatalf("Init: %v", err) + } + if result["status"] != "initialized" { + t.Errorf("status = %v, want %q", result["status"], "initialized") + } + if result["project_path"] != "/tmp/project" { + t.Errorf("project_path = %v, want %q", result["project_path"], "/tmp/project") + } + if result["isolation"] != "worktree" { + t.Errorf("isolation = %v, want %q", result["isolation"], "worktree") + } +} + +func TestInit_DefaultIsolation(t *testing.T) { + store := testStore(t) + + result, err := Init(store, "/tmp/project", "") + if err != nil { + t.Fatalf("Init: %v", err) + } + if result["isolation"] != "reservation" { + t.Errorf("isolation = %v, want %q", result["isolation"], "reservation") + } +} + +func TestInit_MissingProjectPath(t *testing.T) { + store := testStore(t) + + _, err := Init(store, "", "worktree") + if err == nil { + t.Error("expected error for empty project_path") + } +} + +func TestInit_RecordsEvent(t *testing.T) { + store := testStore(t) + + Init(store, "/tmp/project", "worktree") + + var count int + store.DB.QueryRow("SELECT COUNT(*) FROM events WHERE type = 'swarm_init'").Scan(&count) + if count != 1 { + t.Errorf("expected 1 swarm_init event, got %d", count) + } +} diff --git a/internal/swarm/insights.go b/internal/swarm/insights.go new file mode 100644 index 0000000..1c9b9eb --- /dev/null +++ b/internal/swarm/insights.go @@ -0,0 +1,231 @@ +package swarm + +import ( + "encoding/json" + "fmt" + + "github.com/unbound-force/replicator/internal/db" +) + +// GetStrategyInsights queries the events table for historical success rates by strategy. +func GetStrategyInsights(store *db.Store, task string) (map[string]any, error) { + rows, err := store.DB.Query( + "SELECT payload FROM events WHERE type = 'swarm_outcome'", + ) + if err != nil { + return nil, fmt.Errorf("query outcomes: %w", err) + } + defer rows.Close() + + type strategyStats struct { + Total int `json:"total"` + Success int `json:"success"` + } + stats := map[string]*strategyStats{} + + for rows.Next() { + var payloadStr string + if err := rows.Scan(&payloadStr); err != nil { + continue + } + var payload map[string]any + if err := json.Unmarshal([]byte(payloadStr), &payload); err != nil { + continue + } + + strategy, _ := payload["strategy"].(string) + if strategy == "" { + strategy = "unknown" + } + + if _, ok := stats[strategy]; !ok { + stats[strategy] = &strategyStats{} + } + stats[strategy].Total++ + + if success, ok := payload["success"].(bool); ok && success { + stats[strategy].Success++ + } + } + + // Calculate success rates. + rates := map[string]any{} + for strategy, s := range stats { + rate := 0.0 + if s.Total > 0 { + rate = float64(s.Success) / float64(s.Total) * 100 + } + rates[strategy] = map[string]any{ + "total": s.Total, + "success": s.Success, + "success_rate": rate, + } + } + + recommendation := "feature-based" // Default recommendation. + bestRate := 0.0 + for strategy, s := range stats { + if s.Total >= 2 { // Need at least 2 data points. + rate := float64(s.Success) / float64(s.Total) + if rate > bestRate { + bestRate = rate + recommendation = strategy + } + } + } + + return map[string]any{ + "task": task, + "strategies": rates, + "recommendation": recommendation, + }, nil +} + +// GetFileInsights queries the events table for file-specific gotchas. +func GetFileInsights(store *db.Store, files []string) (map[string]any, error) { + if len(files) == 0 { + return map[string]any{ + "files": files, + "insights": map[string]any{}, + }, nil + } + + rows, err := store.DB.Query( + "SELECT payload FROM events WHERE type = 'swarm_outcome'", + ) + if err != nil { + return nil, fmt.Errorf("query outcomes: %w", err) + } + defer rows.Close() + + // Build a set of target files for fast lookup. + fileSet := map[string]bool{} + for _, f := range files { + fileSet[f] = true + } + + type fileStats struct { + Total int `json:"total"` + Failures int `json:"failures"` + ErrorCount int `json:"error_count"` + } + insights := map[string]*fileStats{} + + for rows.Next() { + var payloadStr string + if err := rows.Scan(&payloadStr); err != nil { + continue + } + var payload map[string]any + if err := json.Unmarshal([]byte(payloadStr), &payload); err != nil { + continue + } + + touchedFiles, _ := payload["files_touched"].([]any) + success, _ := payload["success"].(bool) + errorCount := 0 + if ec, ok := payload["error_count"].(float64); ok { + errorCount = int(ec) + } + + for _, tf := range touchedFiles { + fStr, ok := tf.(string) + if !ok { + continue + } + if !fileSet[fStr] { + continue + } + if _, ok := insights[fStr]; !ok { + insights[fStr] = &fileStats{} + } + insights[fStr].Total++ + if !success { + insights[fStr].Failures++ + } + insights[fStr].ErrorCount += errorCount + } + } + + return map[string]any{ + "files": files, + "insights": insights, + }, nil +} + +// GetPatternInsights queries the events table for the top 5 most frequent failure patterns. +func GetPatternInsights(store *db.Store) (map[string]any, error) { + rows, err := store.DB.Query( + "SELECT payload FROM events WHERE type = 'swarm_outcome'", + ) + if err != nil { + return nil, fmt.Errorf("query outcomes: %w", err) + } + defer rows.Close() + + patternCounts := map[string]int{} + totalOutcomes := 0 + totalFailures := 0 + + for rows.Next() { + var payloadStr string + if err := rows.Scan(&payloadStr); err != nil { + continue + } + var payload map[string]any + if err := json.Unmarshal([]byte(payloadStr), &payload); err != nil { + continue + } + + totalOutcomes++ + success, _ := payload["success"].(bool) + if !success { + totalFailures++ + + // Extract failure criteria as patterns. + if criteria, ok := payload["criteria"].([]any); ok { + for _, c := range criteria { + if cStr, ok := c.(string); ok { + patternCounts[cStr]++ + } + } + } + + // Count error types. + if ec, ok := payload["error_count"].(float64); ok && ec > 0 { + patternCounts["errors_encountered"]++ + } + if rc, ok := payload["retry_count"].(float64); ok && rc > 0 { + patternCounts["required_retries"]++ + } + } + } + + // Sort by count and take top 5. + type patternEntry struct { + Pattern string `json:"pattern"` + Count int `json:"count"` + } + var patterns []patternEntry + for p, c := range patternCounts { + patterns = append(patterns, patternEntry{Pattern: p, Count: c}) + } + + // Simple sort -- top 5 by count. + for i := 0; i < len(patterns); i++ { + for j := i + 1; j < len(patterns); j++ { + if patterns[j].Count > patterns[i].Count { + patterns[i], patterns[j] = patterns[j], patterns[i] + } + } + } + if len(patterns) > 5 { + patterns = patterns[:5] + } + + return map[string]any{ + "total_outcomes": totalOutcomes, + "total_failures": totalFailures, + "top_patterns": patterns, + }, nil +} diff --git a/internal/swarm/insights_test.go b/internal/swarm/insights_test.go new file mode 100644 index 0000000..e676a1f --- /dev/null +++ b/internal/swarm/insights_test.go @@ -0,0 +1,155 @@ +package swarm + +import ( + "encoding/json" + "testing" +) + +func TestGetStrategyInsights_Empty(t *testing.T) { + store := testStore(t) + + result, err := GetStrategyInsights(store, "build auth") + if err != nil { + t.Fatalf("GetStrategyInsights: %v", err) + } + if result["task"] != "build auth" { + t.Errorf("task = %v, want %q", result["task"], "build auth") + } + if result["recommendation"] != "feature-based" { + t.Errorf("recommendation = %v, want %q", result["recommendation"], "feature-based") + } +} + +func TestGetStrategyInsights_WithData(t *testing.T) { + store := testStore(t) + + // Insert some outcomes. + outcomes := []struct { + strategy string + success bool + }{ + {"file-based", true}, + {"file-based", true}, + {"file-based", false}, + {"feature-based", true}, + {"feature-based", false}, + } + + for _, o := range outcomes { + payload, _ := json.Marshal(map[string]any{ + "bead_id": "cell-1", + "strategy": o.strategy, + "success": o.success, + }) + store.DB.Exec("INSERT INTO events (type, payload) VALUES (?, ?)", "swarm_outcome", string(payload)) + } + + result, err := GetStrategyInsights(store, "task") + if err != nil { + t.Fatalf("GetStrategyInsights: %v", err) + } + + strategies := result["strategies"].(map[string]any) + fileBased := strategies["file-based"].(map[string]any) + if fileBased["total"] != 3 { + t.Errorf("file-based total = %v, want 3", fileBased["total"]) + } + if fileBased["success"] != 2 { + t.Errorf("file-based success = %v, want 2", fileBased["success"]) + } + + // file-based has 66.7% success, feature-based has 50% -- file-based should be recommended. + if result["recommendation"] != "file-based" { + t.Errorf("recommendation = %v, want %q", result["recommendation"], "file-based") + } +} + +func TestGetFileInsights_Empty(t *testing.T) { + store := testStore(t) + + result, err := GetFileInsights(store, []string{}) + if err != nil { + t.Fatalf("GetFileInsights: %v", err) + } + insights := result["insights"].(map[string]any) + if len(insights) != 0 { + t.Errorf("expected empty insights, got %d", len(insights)) + } +} + +func TestGetFileInsights_WithData(t *testing.T) { + store := testStore(t) + + // Insert outcomes touching specific files. + payloads := []map[string]any{ + {"bead_id": "c1", "success": false, "files_touched": []string{"auth.go"}, "error_count": 2}, + {"bead_id": "c2", "success": true, "files_touched": []string{"auth.go", "db.go"}, "error_count": 0}, + {"bead_id": "c3", "success": false, "files_touched": []string{"db.go"}, "error_count": 1}, + } + for _, p := range payloads { + data, _ := json.Marshal(p) + store.DB.Exec("INSERT INTO events (type, payload) VALUES (?, ?)", "swarm_outcome", string(data)) + } + + result, err := GetFileInsights(store, []string{"auth.go", "db.go"}) + if err != nil { + t.Fatalf("GetFileInsights: %v", err) + } + + // The insights map contains *fileStats but is typed as map[string]any. + // We need to check the values through the map[string]any interface. + insightsRaw := result["insights"] + // Marshal and re-parse to get consistent types. + data, _ := json.Marshal(insightsRaw) + var insights map[string]map[string]int + json.Unmarshal(data, &insights) + + authStats := insights["auth.go"] + if authStats == nil { + t.Fatal("expected insights for auth.go") + } + if authStats["total"] != 2 { + t.Errorf("auth.go total = %d, want 2", authStats["total"]) + } + if authStats["failures"] != 1 { + t.Errorf("auth.go failures = %d, want 1", authStats["failures"]) + } +} + +func TestGetPatternInsights_Empty(t *testing.T) { + store := testStore(t) + + result, err := GetPatternInsights(store) + if err != nil { + t.Fatalf("GetPatternInsights: %v", err) + } + if result["total_outcomes"] != 0 { + t.Errorf("total_outcomes = %v, want 0", result["total_outcomes"]) + } +} + +func TestGetPatternInsights_WithData(t *testing.T) { + store := testStore(t) + + // Insert failure outcomes with criteria. + payloads := []map[string]any{ + {"bead_id": "c1", "success": false, "criteria": []string{"type_error", "test_failure"}, "error_count": 1, "retry_count": 0}, + {"bead_id": "c2", "success": false, "criteria": []string{"type_error"}, "error_count": 2, "retry_count": 1}, + {"bead_id": "c3", "success": true, "criteria": []string{"clean"}, "error_count": 0, "retry_count": 0}, + } + for _, p := range payloads { + data, _ := json.Marshal(p) + store.DB.Exec("INSERT INTO events (type, payload) VALUES (?, ?)", "swarm_outcome", string(data)) + } + + result, err := GetPatternInsights(store) + if err != nil { + t.Fatalf("GetPatternInsights: %v", err) + } + if result["total_outcomes"] != 3 { + t.Errorf("total_outcomes = %v, want 3", result["total_outcomes"]) + } + if result["total_failures"] != 2 { + t.Errorf("total_failures = %v, want 2", result["total_failures"]) + } +} diff --git a/internal/swarm/progress.go b/internal/swarm/progress.go new file mode 100644 index 0000000..541a0d5 --- /dev/null +++ b/internal/swarm/progress.go @@ -0,0 +1,170 @@ +package swarm + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/unbound-force/replicator/internal/db" +) + +// Progress records a progress event for a subtask. +func Progress(store *db.Store, projectKey, agentName, beadID, status string, progressPercent int, message string, filesTouched []string) error { + if beadID == "" { + return fmt.Errorf("bead_id is required") + } + + payload := map[string]any{ + "agent_name": agentName, + "bead_id": beadID, + "status": status, + "progress_percent": progressPercent, + "message": message, + "files_touched": filesTouched, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + _, err = store.DB.Exec( + "INSERT INTO events (type, payload, project_key) VALUES (?, ?, ?)", + "swarm_progress", string(payloadJSON), projectKey, + ) + if err != nil { + return fmt.Errorf("record progress event: %w", err) + } + + return nil +} + +// Complete marks a subtask as complete, recording the event and updating the cell. +func Complete(store *db.Store, projectKey, agentName, beadID, summary string, filesTouched []string, evaluation string, skipVerification, skipReview bool) (map[string]any, error) { + if beadID == "" { + return nil, fmt.Errorf("bead_id is required") + } + + payload := map[string]any{ + "agent_name": agentName, + "bead_id": beadID, + "summary": summary, + "files_touched": filesTouched, + "evaluation": evaluation, + "skip_verification": skipVerification, + "skip_review": skipReview, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + _, err = store.DB.Exec( + "INSERT INTO events (type, payload, project_key) VALUES (?, ?, ?)", + "swarm_complete", string(payloadJSON), projectKey, + ) + if err != nil { + return nil, fmt.Errorf("record complete event: %w", err) + } + + // Update cell status. + // The event is already recorded, so a cell update failure is non-fatal + // but noted in the result for observability. + now := time.Now().UTC().Format(time.RFC3339) + _, cellErr := store.DB.Exec( + "UPDATE beads SET status = 'closed', closed_at = ?, close_reason = ?, updated_at = ? WHERE id = ?", + now, summary, now, beadID, + ) + + result := map[string]any{ + "status": "completed", + "bead_id": beadID, + "agent_name": agentName, + "files_touched": filesTouched, + } + if cellErr != nil { + result["cell_update_error"] = cellErr.Error() + } + return result, nil +} + +// Status aggregates subtask statuses for an epic from events. +func Status(store *db.Store, epicID, projectKey string) (map[string]any, error) { + if epicID == "" { + return nil, fmt.Errorf("epic_id is required") + } + + // Query child cells of the epic. + rows, err := store.DB.Query( + "SELECT id, title, status FROM beads WHERE parent_id = ?", epicID, + ) + if err != nil { + return nil, fmt.Errorf("query subtasks: %w", err) + } + defer rows.Close() + + var subtasks []map[string]string + counts := map[string]int{ + "open": 0, + "in_progress": 0, + "blocked": 0, + "closed": 0, + } + + for rows.Next() { + var id, title, status string + if err := rows.Scan(&id, &title, &status); err != nil { + return nil, fmt.Errorf("scan subtask: %w", err) + } + subtasks = append(subtasks, map[string]string{ + "id": id, + "title": title, + "status": status, + }) + counts[status]++ + } + + total := len(subtasks) + if subtasks == nil { + subtasks = []map[string]string{} + } + + return map[string]any{ + "epic_id": epicID, + "project_key": projectKey, + "total": total, + "counts": counts, + "subtasks": subtasks, + }, nil +} + +// RecordOutcome persists a subtask outcome for implicit feedback scoring. +func RecordOutcome(store *db.Store, beadID string, durationMs int, success bool, strategy string, filesTouched []string, errorCount, retryCount int, criteria []string) error { + if beadID == "" { + return fmt.Errorf("bead_id is required") + } + + payload := map[string]any{ + "bead_id": beadID, + "duration_ms": durationMs, + "success": success, + "strategy": strategy, + "files_touched": filesTouched, + "error_count": errorCount, + "retry_count": retryCount, + "criteria": criteria, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + _, err = store.DB.Exec( + "INSERT INTO events (type, payload) VALUES (?, ?)", + "swarm_outcome", string(payloadJSON), + ) + if err != nil { + return fmt.Errorf("record outcome event: %w", err) + } + + return nil +} diff --git a/internal/swarm/progress_test.go b/internal/swarm/progress_test.go new file mode 100644 index 0000000..79d6525 --- /dev/null +++ b/internal/swarm/progress_test.go @@ -0,0 +1,148 @@ +package swarm + +import ( + "testing" +) + +func TestProgress(t *testing.T) { + store := testStore(t) + + err := Progress(store, "proj-1", "worker-1", "cell-1", "in_progress", 50, "halfway done", []string{"a.go"}) + if err != nil { + t.Fatalf("Progress: %v", err) + } + + var count int + store.DB.QueryRow("SELECT COUNT(*) FROM events WHERE type = 'swarm_progress'").Scan(&count) + if count != 1 { + t.Errorf("expected 1 progress event, got %d", count) + } +} + +func TestProgress_MissingBeadID(t *testing.T) { + store := testStore(t) + err := Progress(store, "proj", "agent", "", "in_progress", 0, "", nil) + if err == nil { + t.Error("expected error for missing bead_id") + } +} + +func TestComplete(t *testing.T) { + store := testStore(t) + + // Create a cell to complete. + store.DB.Exec( + "INSERT INTO beads (id, title, type, status) VALUES (?, ?, ?, ?)", + "cell-comp", "Complete me", "task", "in_progress", + ) + + result, err := Complete(store, "proj-1", "worker-1", "cell-comp", "All done", []string{"a.go"}, "good", false, false) + if err != nil { + t.Fatalf("Complete: %v", err) + } + if result["status"] != "completed" { + t.Errorf("status = %v, want %q", result["status"], "completed") + } + + // Verify event. + var count int + store.DB.QueryRow("SELECT COUNT(*) FROM events WHERE type = 'swarm_complete'").Scan(&count) + if count != 1 { + t.Errorf("expected 1 complete event, got %d", count) + } + + // Verify cell status. + var status string + store.DB.QueryRow("SELECT status FROM beads WHERE id = 'cell-comp'").Scan(&status) + if status != "closed" { + t.Errorf("cell status = %q, want %q", status, "closed") + } +} + +func TestComplete_MissingBeadID(t *testing.T) { + store := testStore(t) + _, err := Complete(store, "proj", "agent", "", "summary", nil, "", false, false) + if err == nil { + t.Error("expected error for missing bead_id") + } +} + +func TestStatus(t *testing.T) { + store := testStore(t) + + // Create an epic with subtasks. + store.DB.Exec("INSERT INTO beads (id, title, type, status) VALUES (?, ?, ?, ?)", + "epic-1", "Epic", "epic", "open") + store.DB.Exec("INSERT INTO beads (id, title, type, status, parent_id) VALUES (?, ?, ?, ?, ?)", + "sub-1", "Sub 1", "task", "open", "epic-1") + store.DB.Exec("INSERT INTO beads (id, title, type, status, parent_id) VALUES (?, ?, ?, ?, ?)", + "sub-2", "Sub 2", "task", "closed", "epic-1") + store.DB.Exec("INSERT INTO beads (id, title, type, status, parent_id) VALUES (?, ?, ?, ?, ?)", + "sub-3", "Sub 3", "task", "in_progress", "epic-1") + + result, err := Status(store, "epic-1", "proj-1") + if err != nil { + t.Fatalf("Status: %v", err) + } + + if result["total"] != 3 { + t.Errorf("total = %v, want 3", result["total"]) + } + + counts := result["counts"].(map[string]int) + if counts["open"] != 1 { + t.Errorf("open = %d, want 1", counts["open"]) + } + if counts["closed"] != 1 { + t.Errorf("closed = %d, want 1", counts["closed"]) + } + if counts["in_progress"] != 1 { + t.Errorf("in_progress = %d, want 1", counts["in_progress"]) + } +} + +func TestStatus_MissingEpicID(t *testing.T) { + store := testStore(t) + _, err := Status(store, "", "proj") + if err == nil { + t.Error("expected error for missing epic_id") + } +} + +func TestStatus_NoSubtasks(t *testing.T) { + store := testStore(t) + + store.DB.Exec("INSERT INTO beads (id, title, type, status) VALUES (?, ?, ?, ?)", + "epic-empty", "Empty Epic", "epic", "open") + + result, err := Status(store, "epic-empty", "proj") + if err != nil { + t.Fatalf("Status: %v", err) + } + if result["total"] != 0 { + t.Errorf("total = %v, want 0", result["total"]) + } +} + +func TestRecordOutcome(t *testing.T) { + store := testStore(t) + + err := RecordOutcome(store, "cell-1", 5000, true, "file-based", []string{"a.go"}, 0, 0, []string{"tests_pass"}) + if err != nil { + t.Fatalf("RecordOutcome: %v", err) + } + + var count int + store.DB.QueryRow("SELECT COUNT(*) FROM events WHERE type = 'swarm_outcome'").Scan(&count) + if count != 1 { + t.Errorf("expected 1 outcome event, got %d", count) + } +} + +func TestRecordOutcome_MissingBeadID(t *testing.T) { + store := testStore(t) + err := RecordOutcome(store, "", 0, false, "", nil, 0, 0, nil) + if err == nil { + t.Error("expected error for missing bead_id") + } +} diff --git a/internal/swarm/review.go b/internal/swarm/review.go new file mode 100644 index 0000000..a6e3b3f --- /dev/null +++ b/internal/swarm/review.go @@ -0,0 +1,211 @@ +package swarm + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/unbound-force/replicator/internal/db" +) + +// Review generates a review prompt for a completed subtask. +// This is a prompt generator -- it does NOT call an LLM. +func Review(projectKey, epicID, taskID string, filesTouched []string) string { + var sb strings.Builder + + sb.WriteString("## Code Review\n\n") + sb.WriteString(fmt.Sprintf("**Project:** %s\n", projectKey)) + sb.WriteString(fmt.Sprintf("**Epic:** %s\n", epicID)) + sb.WriteString(fmt.Sprintf("**Task:** %s\n\n", taskID)) + + if len(filesTouched) > 0 { + sb.WriteString("### Files Modified\n\n") + for _, f := range filesTouched { + sb.WriteString(fmt.Sprintf("- `%s`\n", f)) + } + sb.WriteString("\n") + } + + sb.WriteString("### Review Checklist\n\n") + sb.WriteString("1. Does the code follow project conventions?\n") + sb.WriteString("2. Are all error paths handled?\n") + sb.WriteString("3. Are tests comprehensive and passing?\n") + sb.WriteString("4. Are there any security concerns?\n") + sb.WriteString("5. Is the code well-documented?\n\n") + + sb.WriteString("### Verdict\n\n") + sb.WriteString("Respond with: `approved` or `needs_changes` with specific issues.\n") + + return sb.String() +} + +// ReviewFeedback records review feedback and tracks attempts (max 3). +// After 3 rejections, the task is marked as failed. +func ReviewFeedback(store *db.Store, projectKey, taskID, workerID, status, issues, summary string) (map[string]any, error) { + if taskID == "" { + return nil, fmt.Errorf("task_id is required") + } + if workerID == "" { + return nil, fmt.Errorf("worker_id is required") + } + if status != "approved" && status != "needs_changes" { + return nil, fmt.Errorf("status must be 'approved' or 'needs_changes'") + } + + // Count previous review attempts for this task. + var attemptCount int + store.DB.QueryRow( + "SELECT COUNT(*) FROM events WHERE type = 'review_feedback' AND json_extract(payload, '$.task_id') = ?", + taskID, + ).Scan(&attemptCount) + + attempt := attemptCount + 1 + + payload := map[string]any{ + "project_key": projectKey, + "task_id": taskID, + "worker_id": workerID, + "status": status, + "issues": issues, + "summary": summary, + "attempt": attempt, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + _, err = store.DB.Exec( + "INSERT INTO events (type, payload, project_key) VALUES (?, ?, ?)", + "review_feedback", string(payloadJSON), projectKey, + ) + if err != nil { + return nil, fmt.Errorf("record review feedback: %w", err) + } + + result := map[string]any{ + "status": status, + "attempt": attempt, + "task_id": taskID, + } + + // After 3 rejections, mark as failed. + if status == "needs_changes" && attempt >= 3 { + result["failed"] = true + result["message"] = "task failed after 3 review rejections" + } + + return result, nil +} + +// AdversarialReview generates a VDD-style adversarial review prompt. +// This is a prompt generator -- it does NOT call an LLM. +func AdversarialReview(diff, testOutput string) string { + var sb strings.Builder + + sb.WriteString("## Adversarial Code Review (Sarcasmotron Mode)\n\n") + sb.WriteString("You are a hyper-critical code reviewer with zero tolerance for slop.\n") + sb.WriteString("Review the following diff with fresh eyes and hostile intent.\n\n") + + sb.WriteString("### Diff\n\n") + sb.WriteString("```diff\n") + sb.WriteString(diff) + sb.WriteString("\n```\n\n") + + if testOutput != "" { + sb.WriteString("### Test Output\n\n") + sb.WriteString("```\n") + sb.WriteString(testOutput) + sb.WriteString("\n```\n\n") + } + + sb.WriteString("### Review Criteria\n\n") + sb.WriteString("- Logic errors and edge cases\n") + sb.WriteString("- Security vulnerabilities\n") + sb.WriteString("- Performance issues\n") + sb.WriteString("- Missing error handling\n") + sb.WriteString("- Test coverage gaps\n") + sb.WriteString("- Code style violations\n\n") + + sb.WriteString("### Verdict\n\n") + sb.WriteString("Respond with one of:\n") + sb.WriteString("- `APPROVED`: Code is solid\n") + sb.WriteString("- `NEEDS_CHANGES`: Real issues found (list them)\n") + sb.WriteString("- `HALLUCINATING`: You invented issues (code is excellent)\n") + + return sb.String() +} + +// EvaluationPrompt generates a self-evaluation prompt for a completed subtask. +// This is a prompt generator -- it does NOT call an LLM. +func EvaluationPrompt(beadID, title string, filesTouched []string) string { + var sb strings.Builder + + sb.WriteString("## Self-Evaluation\n\n") + sb.WriteString(fmt.Sprintf("**Task:** %s\n", title)) + sb.WriteString(fmt.Sprintf("**Cell ID:** %s\n\n", beadID)) + + if len(filesTouched) > 0 { + sb.WriteString("### Files Modified\n\n") + for _, f := range filesTouched { + sb.WriteString(fmt.Sprintf("- `%s`\n", f)) + } + sb.WriteString("\n") + } + + sb.WriteString("### Evaluation Criteria\n\n") + sb.WriteString("Rate each criterion 1-5:\n\n") + sb.WriteString("1. **Correctness**: Does the code do what was asked?\n") + sb.WriteString("2. **Completeness**: Are all acceptance criteria met?\n") + sb.WriteString("3. **Code Quality**: Is the code clean and well-structured?\n") + sb.WriteString("4. **Test Coverage**: Are tests comprehensive?\n") + sb.WriteString("5. **Documentation**: Is the code well-documented?\n\n") + + sb.WriteString("### Output Format\n\n") + sb.WriteString("```json\n") + sb.WriteString(`{ + "scores": {"correctness": 5, "completeness": 5, "quality": 5, "tests": 5, "docs": 5}, + "summary": "Brief summary of what was done", + "issues": ["Any remaining issues"] +}`) + sb.WriteString("\n```\n") + + return sb.String() +} + +// Broadcast sends a context update to all agents working on the same epic. +func Broadcast(store *db.Store, projectPath, agentName, epicID, message, importance string, filesAffected []string) error { + if epicID == "" { + return fmt.Errorf("epic_id is required") + } + if message == "" { + return fmt.Errorf("message is required") + } + + if importance == "" { + importance = "info" + } + + payload := map[string]any{ + "agent_name": agentName, + "epic_id": epicID, + "message": message, + "importance": importance, + "files_affected": filesAffected, + "project_path": projectPath, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + _, err = store.DB.Exec( + "INSERT INTO events (type, payload, project_key) VALUES (?, ?, ?)", + "swarm_broadcast", string(payloadJSON), projectPath, + ) + if err != nil { + return fmt.Errorf("record broadcast event: %w", err) + } + + return nil +} diff --git a/internal/swarm/review_test.go b/internal/swarm/review_test.go new file mode 100644 index 0000000..8a3333c --- /dev/null +++ b/internal/swarm/review_test.go @@ -0,0 +1,175 @@ +package swarm + +import ( + "strings" + "testing" +) + +func TestReview(t *testing.T) { + prompt := Review("proj-1", "epic-1", "task-1", []string{"auth.go", "auth_test.go"}) + + checks := []string{"proj-1", "epic-1", "task-1", "auth.go", "auth_test.go", "approved", "needs_changes"} + for _, check := range checks { + if !strings.Contains(prompt, check) { + t.Errorf("review prompt missing %q", check) + } + } +} + +func TestReview_NoFiles(t *testing.T) { + prompt := Review("proj", "epic", "task", nil) + if strings.Contains(prompt, "Files Modified") { + t.Error("should not show files section when empty") + } +} + +func TestReviewFeedback_Approved(t *testing.T) { + store := testStore(t) + + result, err := ReviewFeedback(store, "proj", "task-1", "worker-1", "approved", "", "looks good") + if err != nil { + t.Fatalf("ReviewFeedback: %v", err) + } + if result["status"] != "approved" { + t.Errorf("status = %v, want %q", result["status"], "approved") + } + if result["attempt"] != 1 { + t.Errorf("attempt = %v, want 1", result["attempt"]) + } +} + +func TestReviewFeedback_NeedsChanges(t *testing.T) { + store := testStore(t) + + result, err := ReviewFeedback(store, "proj", "task-1", "worker-1", "needs_changes", "fix error handling", "") + if err != nil { + t.Fatalf("ReviewFeedback: %v", err) + } + if result["status"] != "needs_changes" { + t.Errorf("status = %v, want %q", result["status"], "needs_changes") + } +} + +func TestReviewFeedback_FailsAfter3(t *testing.T) { + store := testStore(t) + + // Submit 3 rejections. + for i := 0; i < 3; i++ { + result, err := ReviewFeedback(store, "proj", "task-fail", "worker-1", "needs_changes", "issues", "") + if err != nil { + t.Fatalf("ReviewFeedback attempt %d: %v", i+1, err) + } + if i == 2 { + if result["failed"] != true { + t.Error("expected failed=true after 3 rejections") + } + } + } +} + +func TestReviewFeedback_MissingTaskID(t *testing.T) { + store := testStore(t) + _, err := ReviewFeedback(store, "proj", "", "worker", "approved", "", "") + if err == nil { + t.Error("expected error for missing task_id") + } +} + +func TestReviewFeedback_MissingWorkerID(t *testing.T) { + store := testStore(t) + _, err := ReviewFeedback(store, "proj", "task", "", "approved", "", "") + if err == nil { + t.Error("expected error for missing worker_id") + } +} + +func TestReviewFeedback_InvalidStatus(t *testing.T) { + store := testStore(t) + _, err := ReviewFeedback(store, "proj", "task", "worker", "invalid", "", "") + if err == nil { + t.Error("expected error for invalid status") + } +} + +func TestAdversarialReview(t *testing.T) { + prompt := AdversarialReview("+ new code\n- old code", "PASS: all tests") + + checks := []string{"Sarcasmotron", "new code", "old code", "PASS: all tests", "APPROVED", "NEEDS_CHANGES", "HALLUCINATING"} + for _, check := range checks { + if !strings.Contains(prompt, check) { + t.Errorf("adversarial review missing %q", check) + } + } +} + +func TestAdversarialReview_NoTestOutput(t *testing.T) { + prompt := AdversarialReview("+ code", "") + if strings.Contains(prompt, "Test Output") { + t.Error("should not show test output section when empty") + } +} + +func TestEvaluationPrompt(t *testing.T) { + prompt := EvaluationPrompt("cell-1", "Implement auth", []string{"auth.go"}) + + checks := []string{"cell-1", "Implement auth", "auth.go", "Correctness", "Completeness"} + for _, check := range checks { + if !strings.Contains(prompt, check) { + t.Errorf("evaluation prompt missing %q", check) + } + } +} + +func TestEvaluationPrompt_NoFiles(t *testing.T) { + prompt := EvaluationPrompt("cell-1", "Task", nil) + if strings.Contains(prompt, "Files Modified") { + t.Error("should not show files section when empty") + } +} + +func TestBroadcast(t *testing.T) { + store := testStore(t) + + err := Broadcast(store, "/tmp/proj", "worker-1", "epic-1", "API changed", "warning", []string{"api.go"}) + if err != nil { + t.Fatalf("Broadcast: %v", err) + } + + var count int + store.DB.QueryRow("SELECT COUNT(*) FROM events WHERE type = 'swarm_broadcast'").Scan(&count) + if count != 1 { + t.Errorf("expected 1 broadcast event, got %d", count) + } +} + +func TestBroadcast_DefaultImportance(t *testing.T) { + store := testStore(t) + + err := Broadcast(store, "/tmp", "agent", "epic-1", "update", "", nil) + if err != nil { + t.Fatalf("Broadcast: %v", err) + } + + // Verify default importance was set. + var payload string + store.DB.QueryRow("SELECT payload FROM events WHERE type = 'swarm_broadcast'").Scan(&payload) + if !strings.Contains(payload, `"info"`) { + t.Errorf("expected default importance 'info' in payload: %s", payload) + } +} + +func TestBroadcast_MissingEpicID(t *testing.T) { + store := testStore(t) + err := Broadcast(store, "/tmp", "agent", "", "msg", "", nil) + if err == nil { + t.Error("expected error for missing epic_id") + } +} + +func TestBroadcast_MissingMessage(t *testing.T) { + store := testStore(t) + err := Broadcast(store, "/tmp", "agent", "epic-1", "", "", nil) + if err == nil { + t.Error("expected error for missing message") + } +} diff --git a/internal/swarm/spawn.go b/internal/swarm/spawn.go new file mode 100644 index 0000000..b34c46e --- /dev/null +++ b/internal/swarm/spawn.go @@ -0,0 +1,115 @@ +package swarm + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/unbound-force/replicator/internal/db" +) + +// SubtaskPrompt generates a prompt for a spawned subtask agent. +// This is a prompt generator -- it does NOT call an LLM. +func SubtaskPrompt(agentName, beadID, epicID, title string, files []string, sharedContext string) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("# Subtask: %s\n\n", title)) + sb.WriteString(fmt.Sprintf("**Agent:** %s\n", agentName)) + sb.WriteString(fmt.Sprintf("**Cell ID:** %s\n", beadID)) + sb.WriteString(fmt.Sprintf("**Epic ID:** %s\n\n", epicID)) + + if len(files) > 0 { + sb.WriteString("## Files to Modify\n\n") + for _, f := range files { + sb.WriteString(fmt.Sprintf("- `%s`\n", f)) + } + sb.WriteString("\n") + } + + if sharedContext != "" { + sb.WriteString("## Shared Context\n\n") + sb.WriteString(sharedContext) + sb.WriteString("\n\n") + } + + sb.WriteString("## Instructions\n\n") + sb.WriteString("1. Reserve files before editing using `swarmmail_reserve`\n") + sb.WriteString("2. Implement the changes described above\n") + sb.WriteString("3. Write tests for new code\n") + sb.WriteString("4. Report progress with `swarm_progress`\n") + sb.WriteString("5. Complete with `swarm_complete` when done\n") + + return sb.String() +} + +// SpawnSubtask prepares metadata for spawning a subtask agent. +// Returns the metadata needed to launch the agent. +func SpawnSubtask(beadID, epicID, title string, files []string, description, sharedContext string) (map[string]any, error) { + if beadID == "" { + return nil, fmt.Errorf("bead_id is required") + } + if epicID == "" { + return nil, fmt.Errorf("epic_id is required") + } + if title == "" { + return nil, fmt.Errorf("title is required") + } + + result := map[string]any{ + "status": "ready_to_spawn", + "bead_id": beadID, + "epic_id": epicID, + "title": title, + "files": files, + "description": description, + "shared_context": sharedContext, + "timestamp": time.Now().UTC().Format(time.RFC3339), + } + + return result, nil +} + +// CompleteSubtask records the completion of a subtask and updates the cell status. +func CompleteSubtask(store *db.Store, beadID, taskResult string, filesTouched []string) (map[string]any, error) { + if beadID == "" { + return nil, fmt.Errorf("bead_id is required") + } + + payload := map[string]any{ + "bead_id": beadID, + "task_result": taskResult, + "files_touched": filesTouched, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + _, err = store.DB.Exec( + "INSERT INTO events (type, payload) VALUES (?, ?)", + "subtask_complete", string(payloadJSON), + ) + if err != nil { + return nil, fmt.Errorf("record subtask_complete event: %w", err) + } + + // Update cell status to closed. + // The event is already recorded, so a cell update failure is non-fatal + // but noted in the result for observability. + now := time.Now().UTC().Format(time.RFC3339) + _, cellErr := store.DB.Exec( + "UPDATE beads SET status = 'closed', closed_at = ?, close_reason = 'completed', updated_at = ? WHERE id = ?", + now, now, beadID, + ) + + result := map[string]any{ + "status": "completed", + "bead_id": beadID, + "files_touched": filesTouched, + } + if cellErr != nil { + result["cell_update_error"] = cellErr.Error() + } + return result, nil +} diff --git a/internal/swarm/spawn_test.go b/internal/swarm/spawn_test.go new file mode 100644 index 0000000..a5ffb29 --- /dev/null +++ b/internal/swarm/spawn_test.go @@ -0,0 +1,106 @@ +package swarm + +import ( + "strings" + "testing" +) + +func TestSubtaskPrompt(t *testing.T) { + prompt := SubtaskPrompt( + "worker-1", "cell-abc", "epic-xyz", + "Implement auth", + []string{"auth.go", "auth_test.go"}, + "Use JWT tokens", + ) + + checks := []string{ + "Implement auth", + "worker-1", + "cell-abc", + "epic-xyz", + "auth.go", + "auth_test.go", + "JWT tokens", + "swarmmail_reserve", + "swarm_complete", + } + for _, check := range checks { + if !strings.Contains(prompt, check) { + t.Errorf("prompt missing %q", check) + } + } +} + +func TestSubtaskPrompt_NoFiles(t *testing.T) { + prompt := SubtaskPrompt("agent", "id", "epic", "Title", nil, "") + if strings.Contains(prompt, "Files to Modify") { + t.Error("should not show files section when empty") + } +} + +func TestSpawnSubtask(t *testing.T) { + result, err := SpawnSubtask("cell-1", "epic-1", "Do thing", []string{"a.go"}, "desc", "ctx") + if err != nil { + t.Fatalf("SpawnSubtask: %v", err) + } + if result["status"] != "ready_to_spawn" { + t.Errorf("status = %v, want %q", result["status"], "ready_to_spawn") + } + if result["bead_id"] != "cell-1" { + t.Errorf("bead_id = %v, want %q", result["bead_id"], "cell-1") + } +} + +func TestSpawnSubtask_MissingBeadID(t *testing.T) { + _, err := SpawnSubtask("", "epic-1", "title", nil, "", "") + if err == nil { + t.Error("expected error for missing bead_id") + } +} + +func TestSpawnSubtask_MissingEpicID(t *testing.T) { + _, err := SpawnSubtask("cell-1", "", "title", nil, "", "") + if err == nil { + t.Error("expected error for missing epic_id") + } +} + +func TestSpawnSubtask_MissingTitle(t *testing.T) { + _, err := SpawnSubtask("cell-1", "epic-1", "", nil, "", "") + if err == nil { + t.Error("expected error for missing title") + } +} + +func TestCompleteSubtask(t *testing.T) { + store := testStore(t) + + // Create a cell to complete. + store.DB.Exec( + "INSERT INTO beads (id, title, type, status) VALUES (?, ?, ?, ?)", + "cell-test", "Test task", "task", "in_progress", + ) + + result, err := CompleteSubtask(store, "cell-test", "All done", []string{"a.go"}) + if err != nil { + t.Fatalf("CompleteSubtask: %v", err) + } + if result["status"] != "completed" { + t.Errorf("status = %v, want %q", result["status"], "completed") + } + + // Verify event was recorded. + var count int + store.DB.QueryRow("SELECT COUNT(*) FROM events WHERE type = 'subtask_complete'").Scan(&count) + if count != 1 { + t.Errorf("expected 1 subtask_complete event, got %d", count) + } +} + +func TestCompleteSubtask_MissingBeadID(t *testing.T) { + store := testStore(t) + _, err := CompleteSubtask(store, "", "result", nil) + if err == nil { + t.Error("expected error for missing bead_id") + } +} diff --git a/internal/swarm/worktree.go b/internal/swarm/worktree.go new file mode 100644 index 0000000..e1a6197 --- /dev/null +++ b/internal/swarm/worktree.go @@ -0,0 +1,121 @@ +package swarm + +import ( + "fmt" + "path/filepath" + + "github.com/unbound-force/replicator/internal/gitutil" +) + +// WorktreeCreate creates a git worktree for isolated task execution. +// The worktree is placed at /.worktrees/. +func WorktreeCreate(projectPath, taskID, startCommit string) (map[string]any, error) { + if projectPath == "" { + return nil, fmt.Errorf("project_path is required") + } + if taskID == "" { + return nil, fmt.Errorf("task_id is required") + } + if startCommit == "" { + return nil, fmt.Errorf("start_commit is required") + } + + worktreePath := filepath.Join(projectPath, ".worktrees", taskID) + branch := "swarm/" + taskID + + if err := gitutil.WorktreeAdd(projectPath, worktreePath, branch, startCommit); err != nil { + return nil, fmt.Errorf("create worktree: %w", err) + } + + return map[string]any{ + "status": "created", + "worktree_path": worktreePath, + "branch": branch, + "start_commit": startCommit, + "task_id": taskID, + }, nil +} + +// WorktreeMerge cherry-picks commits from a worktree branch back to the main branch. +func WorktreeMerge(projectPath, taskID, startCommit string) (map[string]any, error) { + if projectPath == "" { + return nil, fmt.Errorf("project_path is required") + } + if taskID == "" { + return nil, fmt.Errorf("task_id is required") + } + + branch := "swarm/" + taskID + + if err := gitutil.CherryPick(projectPath, branch, startCommit); err != nil { + return nil, fmt.Errorf("merge worktree: %w", err) + } + + return map[string]any{ + "status": "merged", + "branch": branch, + "task_id": taskID, + }, nil +} + +// WorktreeCleanup removes a worktree. If cleanupAll is true, removes all +// worktrees in the .worktrees directory. Idempotent -- safe to call multiple times. +func WorktreeCleanup(projectPath string, taskID string, cleanupAll bool) (map[string]any, error) { + if projectPath == "" { + return nil, fmt.Errorf("project_path is required") + } + + if cleanupAll { + worktrees, err := gitutil.WorktreeList(projectPath) + if err != nil { + return nil, fmt.Errorf("list worktrees: %w", err) + } + + removed := 0 + worktreeDir := filepath.Join(projectPath, ".worktrees") + for _, wt := range worktrees { + // Only remove worktrees under .worktrees/. + if len(wt.Path) > len(worktreeDir) && wt.Path[:len(worktreeDir)] == worktreeDir { + // Ignore errors for idempotency. + if err := gitutil.WorktreeRemove(projectPath, wt.Path); err == nil { + removed++ + } + } + } + + return map[string]any{ + "status": "cleaned", + "removed": removed, + }, nil + } + + if taskID == "" { + return nil, fmt.Errorf("task_id is required when cleanup_all is false") + } + + worktreePath := filepath.Join(projectPath, ".worktrees", taskID) + // Idempotent: ignore errors if worktree doesn't exist. + gitutil.WorktreeRemove(projectPath, worktreePath) + + return map[string]any{ + "status": "cleaned", + "task_id": taskID, + }, nil +} + +// WorktreeList lists all active worktrees for a project. +func WorktreeList(projectPath string) (map[string]any, error) { + if projectPath == "" { + return nil, fmt.Errorf("project_path is required") + } + + worktrees, err := gitutil.WorktreeList(projectPath) + if err != nil { + return nil, fmt.Errorf("list worktrees: %w", err) + } + + return map[string]any{ + "worktrees": worktrees, + "count": len(worktrees), + }, nil +} diff --git a/internal/swarm/worktree_test.go b/internal/swarm/worktree_test.go new file mode 100644 index 0000000..55b0ad0 --- /dev/null +++ b/internal/swarm/worktree_test.go @@ -0,0 +1,182 @@ +package swarm + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/unbound-force/replicator/internal/gitutil" +) + +// initRepo creates a temporary git repo with an initial commit for worktree tests. +func initRepo(t *testing.T) string { + t.Helper() + if testing.Short() { + t.Skip("requires git") + } + + dir := t.TempDir() + run := func(args ...string) { + t.Helper() + if _, err := gitutil.Run(dir, args...); err != nil { + t.Fatalf("git %s: %v", strings.Join(args, " "), err) + } + } + + run("init") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + f := filepath.Join(dir, "README.md") + if err := os.WriteFile(f, []byte("# test\n"), 0o644); err != nil { + t.Fatal(err) + } + run("add", ".") + run("commit", "-m", "initial commit") + + return dir +} + +func TestWorktreeCreate(t *testing.T) { + repo := initRepo(t) + commit, _ := gitutil.CurrentCommit(repo) + + result, err := WorktreeCreate(repo, "task-1", commit) + if err != nil { + t.Fatalf("WorktreeCreate: %v", err) + } + if result["status"] != "created" { + t.Errorf("status = %v, want %q", result["status"], "created") + } + + wtPath := result["worktree_path"].(string) + if _, err := os.Stat(wtPath); os.IsNotExist(err) { + t.Error("worktree directory should exist") + } +} + +func TestWorktreeCreate_MissingArgs(t *testing.T) { + tests := []struct { + name string + projectPath string + taskID string + startCommit string + }{ + {"missing project_path", "", "task", "abc"}, + {"missing task_id", "/tmp", "", "abc"}, + {"missing start_commit", "/tmp", "task", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := WorktreeCreate(tt.projectPath, tt.taskID, tt.startCommit) + if err == nil { + t.Error("expected error") + } + }) + } +} + +func TestWorktreeMerge(t *testing.T) { + repo := initRepo(t) + commit, _ := gitutil.CurrentCommit(repo) + + // Create worktree and make a commit. + WorktreeCreate(repo, "merge-task", commit) + wtPath := filepath.Join(repo, ".worktrees", "merge-task") + + f := filepath.Join(wtPath, "new.txt") + os.WriteFile(f, []byte("merge content\n"), 0o644) + gitutil.Run(wtPath, "add", ".") + gitutil.Run(wtPath, "commit", "-m", "worktree commit") + + result, err := WorktreeMerge(repo, "merge-task", commit) + if err != nil { + t.Fatalf("WorktreeMerge: %v", err) + } + if result["status"] != "merged" { + t.Errorf("status = %v, want %q", result["status"], "merged") + } + + // Verify file exists in main repo. + mainFile := filepath.Join(repo, "new.txt") + if _, err := os.Stat(mainFile); os.IsNotExist(err) { + t.Error("merged file should exist in main repo") + } +} + +func TestWorktreeMerge_MissingArgs(t *testing.T) { + _, err := WorktreeMerge("", "task", "abc") + if err == nil { + t.Error("expected error for missing project_path") + } + _, err = WorktreeMerge("/tmp", "", "abc") + if err == nil { + t.Error("expected error for missing task_id") + } +} + +func TestWorktreeCleanup(t *testing.T) { + repo := initRepo(t) + commit, _ := gitutil.CurrentCommit(repo) + + WorktreeCreate(repo, "cleanup-task", commit) + + result, err := WorktreeCleanup(repo, "cleanup-task", false) + if err != nil { + t.Fatalf("WorktreeCleanup: %v", err) + } + if result["status"] != "cleaned" { + t.Errorf("status = %v, want %q", result["status"], "cleaned") + } +} + +func TestWorktreeCleanup_Idempotent(t *testing.T) { + repo := initRepo(t) + + // Cleanup a non-existent worktree should not error. + result, err := WorktreeCleanup(repo, "nonexistent", false) + if err != nil { + t.Fatalf("WorktreeCleanup: %v", err) + } + if result["status"] != "cleaned" { + t.Errorf("status = %v, want %q", result["status"], "cleaned") + } +} + +func TestWorktreeCleanup_MissingProjectPath(t *testing.T) { + _, err := WorktreeCleanup("", "", false) + if err == nil { + t.Error("expected error for missing project_path") + } +} + +func TestWorktreeCleanup_MissingTaskID(t *testing.T) { + if testing.Short() { + t.Skip("requires git") + } + _, err := WorktreeCleanup("/tmp", "", false) + if err == nil { + t.Error("expected error for missing task_id when cleanup_all is false") + } +} + +func TestWorktreeList(t *testing.T) { + repo := initRepo(t) + + result, err := WorktreeList(repo) + if err != nil { + t.Fatalf("WorktreeList: %v", err) + } + count := result["count"].(int) + if count < 1 { + t.Errorf("expected at least 1 worktree, got %d", count) + } +} + +func TestWorktreeList_MissingProjectPath(t *testing.T) { + _, err := WorktreeList("") + if err == nil { + t.Error("expected error for missing project_path") + } +} diff --git a/internal/swarmmail/agent.go b/internal/swarmmail/agent.go new file mode 100644 index 0000000..91459c2 --- /dev/null +++ b/internal/swarmmail/agent.go @@ -0,0 +1,60 @@ +// Package swarmmail provides agent messaging and file reservation for swarm coordination. +// +// Agents register themselves, send messages to each other, and reserve files +// for exclusive editing to prevent conflicts during parallel work. +package swarmmail + +import ( + "fmt" + "time" + + "github.com/unbound-force/replicator/internal/db" +) + +// Agent represents a registered swarm agent. +// +// TaskDescription is always serialized (no omitempty) to maintain shape +// parity with the TypeScript cyborg-swarm responses. +type Agent struct { + ID int `json:"id"` + Name string `json:"name"` + ProjectPath string `json:"project_path"` + TaskDescription string `json:"task_description"` + Status string `json:"status"` +} + +// Init registers or updates an agent in the swarm. +// Uses upsert semantics -- if the agent already exists, updates its metadata. +func Init(store *db.Store, agentName, projectPath, taskDescription string) (*Agent, error) { + if agentName == "" { + return nil, fmt.Errorf("agent name is required") + } + + now := time.Now().UTC().Format(time.RFC3339) + + // Upsert: insert or update on conflict. + _, err := store.DB.Exec(` + INSERT INTO agents (name, project_path, task_description, last_seen_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + project_path = excluded.project_path, + task_description = excluded.task_description, + last_seen_at = excluded.last_seen_at`, + agentName, projectPath, taskDescription, now, + ) + if err != nil { + return nil, fmt.Errorf("upsert agent: %w", err) + } + + // Read back the agent. + var agent Agent + err = store.DB.QueryRow( + "SELECT id, name, project_path, COALESCE(task_description, ''), status FROM agents WHERE name = ?", + agentName, + ).Scan(&agent.ID, &agent.Name, &agent.ProjectPath, &agent.TaskDescription, &agent.Status) + if err != nil { + return nil, fmt.Errorf("read agent: %w", err) + } + + return &agent, nil +} diff --git a/internal/swarmmail/agent_test.go b/internal/swarmmail/agent_test.go new file mode 100644 index 0000000..4463cbf --- /dev/null +++ b/internal/swarmmail/agent_test.go @@ -0,0 +1,73 @@ +package swarmmail + +import ( + "testing" + + "github.com/unbound-force/replicator/internal/db" +) + +func testStore(t *testing.T) *db.Store { + t.Helper() + store, err := db.OpenMemory() + if err != nil { + t.Fatalf("OpenMemory: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +func TestInit(t *testing.T) { + store := testStore(t) + + agent, err := Init(store, "worker-1", "/project", "implement feature X") + if err != nil { + t.Fatalf("Init: %v", err) + } + if agent.Name != "worker-1" { + t.Errorf("name = %q, want %q", agent.Name, "worker-1") + } + if agent.ProjectPath != "/project" { + t.Errorf("project_path = %q, want %q", agent.ProjectPath, "/project") + } + if agent.TaskDescription != "implement feature X" { + t.Errorf("task_description = %q, want %q", agent.TaskDescription, "implement feature X") + } + if agent.Status != "active" { + t.Errorf("status = %q, want %q", agent.Status, "active") + } +} + +func TestInit_Upsert(t *testing.T) { + store := testStore(t) + + // First init. + agent1, err := Init(store, "worker-1", "/old", "old task") + if err != nil { + t.Fatalf("first Init: %v", err) + } + + // Second init with same name -- should update. + agent2, err := Init(store, "worker-1", "/new", "new task") + if err != nil { + t.Fatalf("second Init: %v", err) + } + + if agent2.ID != agent1.ID { + t.Errorf("ID changed from %d to %d on upsert", agent1.ID, agent2.ID) + } + if agent2.ProjectPath != "/new" { + t.Errorf("project_path = %q, want %q", agent2.ProjectPath, "/new") + } + if agent2.TaskDescription != "new task" { + t.Errorf("task_description = %q, want %q", agent2.TaskDescription, "new task") + } +} + +func TestInit_EmptyName(t *testing.T) { + store := testStore(t) + + _, err := Init(store, "", "/project", "task") + if err == nil { + t.Error("expected error for empty agent name") + } +} diff --git a/internal/swarmmail/message.go b/internal/swarmmail/message.go new file mode 100644 index 0000000..77c6b4f --- /dev/null +++ b/internal/swarmmail/message.go @@ -0,0 +1,161 @@ +package swarmmail + +import ( + "encoding/json" + "fmt" + + "github.com/unbound-force/replicator/internal/db" +) + +// Message represents a full swarm mail message. +type Message struct { + ID int `json:"id"` + FromAgent string `json:"from_agent"` + ToAgents string `json:"to_agents"` + Subject string `json:"subject"` + Body string `json:"body"` + Importance string `json:"importance"` + ThreadID string `json:"thread_id,omitempty"` + AckRequired bool `json:"ack_required"` + Acknowledged bool `json:"acknowledged"` + CreatedAt string `json:"created_at"` +} + +// MessageSummary is a message without the body, used for inbox listings. +type MessageSummary struct { + ID int `json:"id"` + FromAgent string `json:"from_agent"` + Subject string `json:"subject"` + Importance string `json:"importance"` + ThreadID string `json:"thread_id,omitempty"` + AckRequired bool `json:"ack_required"` + Acknowledged bool `json:"acknowledged"` + CreatedAt string `json:"created_at"` +} + +// SendInput defines the input for sending a message. +type SendInput struct { + To []string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` + Importance string `json:"importance,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + AckRequired bool `json:"ack_required,omitempty"` +} + +// Send delivers a message to one or more agents. +func Send(store *db.Store, fromAgent string, input SendInput) error { + importance := input.Importance + if importance == "" { + importance = "normal" + } + + toJSON, err := json.Marshal(input.To) + if err != nil { + return fmt.Errorf("marshal to_agents: %w", err) + } + + ackRequired := 0 + if input.AckRequired { + ackRequired = 1 + } + + _, err = store.DB.Exec(` + INSERT INTO messages (from_agent, to_agents, subject, body, importance, thread_id, ack_required) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + fromAgent, string(toJSON), input.Subject, input.Body, importance, input.ThreadID, ackRequired, + ) + if err != nil { + return fmt.Errorf("insert message: %w", err) + } + + return nil +} + +// Inbox returns message summaries (no body) for an agent, max 5 results. +func Inbox(store *db.Store, agentName string, limit int, urgentOnly bool) ([]MessageSummary, error) { + if limit <= 0 || limit > 5 { + limit = 5 + } + + // Use json_each() to match the agent name as an exact JSON array element, + // avoiding partial name collisions (e.g., "worker-1" matching "worker-10"). + query := ` + SELECT id, from_agent, subject, importance, thread_id, ack_required, acknowledged, created_at + FROM messages + WHERE EXISTS (SELECT 1 FROM json_each(to_agents) WHERE json_each.value = ?)` + args := []any{agentName} + + if urgentOnly { + query += " AND importance = 'urgent'" + } + + query += " ORDER BY created_at DESC LIMIT ?" + args = append(args, limit) + + rows, err := store.DB.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("query inbox: %w", err) + } + defer rows.Close() + + var summaries []MessageSummary + for rows.Next() { + var s MessageSummary + var threadID *string + var ackReq, acked int + err := rows.Scan(&s.ID, &s.FromAgent, &s.Subject, &s.Importance, + &threadID, &ackReq, &acked, &s.CreatedAt) + if err != nil { + return nil, fmt.Errorf("scan message: %w", err) + } + if threadID != nil { + s.ThreadID = *threadID + } + s.AckRequired = ackReq == 1 + s.Acknowledged = acked == 1 + summaries = append(summaries, s) + } + + if summaries == nil { + summaries = []MessageSummary{} + } + return summaries, nil +} + +// ReadMessage returns a full message by ID. +func ReadMessage(store *db.Store, messageID int) (*Message, error) { + var m Message + var threadID *string + var ackReq, acked int + err := store.DB.QueryRow(` + SELECT id, from_agent, to_agents, subject, body, importance, thread_id, + ack_required, acknowledged, created_at + FROM messages WHERE id = ?`, messageID, + ).Scan(&m.ID, &m.FromAgent, &m.ToAgents, &m.Subject, &m.Body, &m.Importance, + &threadID, &ackReq, &acked, &m.CreatedAt) + if err != nil { + return nil, fmt.Errorf("read message %d: %w", messageID, err) + } + if threadID != nil { + m.ThreadID = *threadID + } + m.AckRequired = ackReq == 1 + m.Acknowledged = acked == 1 + return &m, nil +} + +// Ack acknowledges a message. +func Ack(store *db.Store, messageID int) error { + result, err := store.DB.Exec( + "UPDATE messages SET acknowledged = 1 WHERE id = ?", messageID, + ) + if err != nil { + return fmt.Errorf("ack message: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("message %d not found", messageID) + } + return nil +} diff --git a/internal/swarmmail/message_test.go b/internal/swarmmail/message_test.go new file mode 100644 index 0000000..064c88a --- /dev/null +++ b/internal/swarmmail/message_test.go @@ -0,0 +1,188 @@ +package swarmmail + +import "testing" + +func TestSendAndReadMessage(t *testing.T) { + store := testStore(t) + + err := Send(store, "coordinator", SendInput{ + To: []string{"worker-1"}, + Subject: "Start task", + Body: "Please begin implementing feature X", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + + // Read the message. + msg, err := ReadMessage(store, 1) + if err != nil { + t.Fatalf("ReadMessage: %v", err) + } + if msg.FromAgent != "coordinator" { + t.Errorf("from_agent = %q, want %q", msg.FromAgent, "coordinator") + } + if msg.Subject != "Start task" { + t.Errorf("subject = %q, want %q", msg.Subject, "Start task") + } + if msg.Body != "Please begin implementing feature X" { + t.Errorf("body = %q, want %q", msg.Body, "Please begin implementing feature X") + } + if msg.Importance != "normal" { + t.Errorf("importance = %q, want %q", msg.Importance, "normal") + } +} + +func TestSend_WithImportance(t *testing.T) { + store := testStore(t) + + err := Send(store, "coordinator", SendInput{ + To: []string{"worker-1"}, + Subject: "Urgent", + Body: "Stop everything", + Importance: "urgent", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + + msg, err := ReadMessage(store, 1) + if err != nil { + t.Fatalf("ReadMessage: %v", err) + } + if msg.Importance != "urgent" { + t.Errorf("importance = %q, want %q", msg.Importance, "urgent") + } +} + +func TestInbox(t *testing.T) { + store := testStore(t) + + // Send 3 messages to worker-1. + for i := 0; i < 3; i++ { + Send(store, "coordinator", SendInput{ + To: []string{"worker-1"}, + Subject: "Message", + Body: "body", + }) + } + + summaries, err := Inbox(store, "worker-1", 5, false) + if err != nil { + t.Fatalf("Inbox: %v", err) + } + if len(summaries) != 3 { + t.Errorf("expected 3 messages, got %d", len(summaries)) + } + // Summaries should not contain body (it's not in the struct). +} + +func TestInbox_MaxFive(t *testing.T) { + store := testStore(t) + + // Send 7 messages. + for i := 0; i < 7; i++ { + Send(store, "coordinator", SendInput{ + To: []string{"worker-1"}, + Subject: "Message", + Body: "body", + }) + } + + summaries, err := Inbox(store, "worker-1", 0, false) + if err != nil { + t.Fatalf("Inbox: %v", err) + } + if len(summaries) != 5 { + t.Errorf("expected max 5 messages, got %d", len(summaries)) + } +} + +func TestInbox_UrgentOnly(t *testing.T) { + store := testStore(t) + + Send(store, "coordinator", SendInput{ + To: []string{"worker-1"}, + Subject: "Normal", + Body: "normal body", + Importance: "normal", + }) + Send(store, "coordinator", SendInput{ + To: []string{"worker-1"}, + Subject: "Urgent", + Body: "urgent body", + Importance: "urgent", + }) + + summaries, err := Inbox(store, "worker-1", 5, true) + if err != nil { + t.Fatalf("Inbox: %v", err) + } + if len(summaries) != 1 { + t.Errorf("expected 1 urgent message, got %d", len(summaries)) + } + if summaries[0].Subject != "Urgent" { + t.Errorf("subject = %q, want %q", summaries[0].Subject, "Urgent") + } +} + +func TestInbox_Empty(t *testing.T) { + store := testStore(t) + + summaries, err := Inbox(store, "worker-1", 5, false) + if err != nil { + t.Fatalf("Inbox: %v", err) + } + if len(summaries) != 0 { + t.Errorf("expected 0 messages, got %d", len(summaries)) + } +} + +func TestAck(t *testing.T) { + store := testStore(t) + + Send(store, "coordinator", SendInput{ + To: []string{"worker-1"}, + Subject: "Ack me", + Body: "body", + AckRequired: true, + }) + + // Verify ack_required is set. + msg, _ := ReadMessage(store, 1) + if !msg.AckRequired { + t.Error("ack_required should be true") + } + if msg.Acknowledged { + t.Error("acknowledged should be false initially") + } + + // Ack the message. + if err := Ack(store, 1); err != nil { + t.Fatalf("Ack: %v", err) + } + + // Verify acknowledged. + msg, _ = ReadMessage(store, 1) + if !msg.Acknowledged { + t.Error("acknowledged should be true after ack") + } +} + +func TestAck_NotFound(t *testing.T) { + store := testStore(t) + + err := Ack(store, 999) + if err == nil { + t.Error("expected error for nonexistent message") + } +} + +func TestReadMessage_NotFound(t *testing.T) { + store := testStore(t) + + _, err := ReadMessage(store, 999) + if err == nil { + t.Error("expected error for nonexistent message") + } +} diff --git a/internal/swarmmail/reservation.go b/internal/swarmmail/reservation.go new file mode 100644 index 0000000..26a67a1 --- /dev/null +++ b/internal/swarmmail/reservation.go @@ -0,0 +1,139 @@ +package swarmmail + +import ( + "fmt" + "time" + + "github.com/unbound-force/replicator/internal/db" +) + +// Reservation represents a file path reservation. +type Reservation struct { + ID int `json:"id"` + AgentName string `json:"agent_name"` + Path string `json:"path"` + Exclusive bool `json:"exclusive"` + Reason string `json:"reason,omitempty"` + TTLSeconds int `json:"ttl_seconds"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` +} + +// Reserve creates file reservations for the given paths. +// Checks for exclusive conflicts (excluding expired reservations). +func Reserve(store *db.Store, agentName string, paths []string, exclusive bool, reason string, ttlSeconds int) ([]Reservation, error) { + if ttlSeconds <= 0 { + ttlSeconds = 300 + } + + now := time.Now().UTC() + expiresAt := now.Add(time.Duration(ttlSeconds) * time.Second).Format(time.RFC3339) + nowStr := now.Format(time.RFC3339) + + tx, err := store.DB.Begin() + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + // Check for exclusive conflicts on each path. + for _, path := range paths { + var count int + err := tx.QueryRow(` + SELECT COUNT(*) FROM reservations + WHERE path = ? + AND exclusive = 1 + AND agent_name != ? + AND expires_at > ?`, + path, agentName, nowStr, + ).Scan(&count) + if err != nil { + return nil, fmt.Errorf("check conflict for %q: %w", path, err) + } + if count > 0 { + return nil, fmt.Errorf("path %q is exclusively reserved by another agent", path) + } + } + + exclusiveInt := 0 + if exclusive { + exclusiveInt = 1 + } + + reservations := make([]Reservation, 0, len(paths)) + for _, path := range paths { + result, err := tx.Exec(` + INSERT INTO reservations (agent_name, path, exclusive, reason, ttl_seconds, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + agentName, path, exclusiveInt, reason, ttlSeconds, nowStr, expiresAt, + ) + if err != nil { + return nil, fmt.Errorf("insert reservation for %q: %w", path, err) + } + + id, _ := result.LastInsertId() + reservations = append(reservations, Reservation{ + ID: int(id), + AgentName: agentName, + Path: path, + Exclusive: exclusive, + Reason: reason, + TTLSeconds: ttlSeconds, + CreatedAt: nowStr, + ExpiresAt: expiresAt, + }) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit reservations: %w", err) + } + + return reservations, nil +} + +// Release removes reservations by path or reservation ID. +func Release(store *db.Store, paths []string, reservationIDs []int) error { + if len(paths) == 0 && len(reservationIDs) == 0 { + return fmt.Errorf("must specify paths or reservation IDs to release") + } + + for _, path := range paths { + _, err := store.DB.Exec("DELETE FROM reservations WHERE path = ?", path) + if err != nil { + return fmt.Errorf("release path %q: %w", path, err) + } + } + + for _, id := range reservationIDs { + _, err := store.DB.Exec("DELETE FROM reservations WHERE id = ?", id) + if err != nil { + return fmt.Errorf("release reservation %d: %w", id, err) + } + } + + return nil +} + +// ReleaseAll removes all reservations in a project. +// If projectPath is empty, removes all reservations regardless of project. +func ReleaseAll(store *db.Store, projectPath string) error { + var err error + if projectPath == "" { + _, err = store.DB.Exec("DELETE FROM reservations") + } else { + _, err = store.DB.Exec("DELETE FROM reservations WHERE project_path = ? OR project_path IS NULL OR project_path = ''", projectPath) + } + if err != nil { + return fmt.Errorf("release all: %w", err) + } + return nil +} + +// ReleaseAgent removes all reservations for a specific agent. +func ReleaseAgent(store *db.Store, agentName string) error { + _, err := store.DB.Exec("DELETE FROM reservations WHERE agent_name = ?", agentName) + if err != nil { + return fmt.Errorf("release agent %q: %w", agentName, err) + } + return nil +} diff --git a/internal/swarmmail/reservation_test.go b/internal/swarmmail/reservation_test.go new file mode 100644 index 0000000..2c24358 --- /dev/null +++ b/internal/swarmmail/reservation_test.go @@ -0,0 +1,166 @@ +package swarmmail + +import "testing" + +func TestReserve(t *testing.T) { + store := testStore(t) + + reservations, err := Reserve(store, "worker-1", []string{"foo.go", "bar.go"}, true, "implementing feature", 300) + if err != nil { + t.Fatalf("Reserve: %v", err) + } + if len(reservations) != 2 { + t.Fatalf("expected 2 reservations, got %d", len(reservations)) + } + if reservations[0].Path != "foo.go" { + t.Errorf("path = %q, want %q", reservations[0].Path, "foo.go") + } + if reservations[0].AgentName != "worker-1" { + t.Errorf("agent_name = %q, want %q", reservations[0].AgentName, "worker-1") + } + if !reservations[0].Exclusive { + t.Error("exclusive should be true") + } + if reservations[0].TTLSeconds != 300 { + t.Errorf("ttl_seconds = %d, want 300", reservations[0].TTLSeconds) + } +} + +func TestReserve_DefaultTTL(t *testing.T) { + store := testStore(t) + + reservations, err := Reserve(store, "worker-1", []string{"foo.go"}, true, "test", 0) + if err != nil { + t.Fatalf("Reserve: %v", err) + } + if reservations[0].TTLSeconds != 300 { + t.Errorf("default ttl = %d, want 300", reservations[0].TTLSeconds) + } +} + +func TestReserve_ExclusiveConflict(t *testing.T) { + store := testStore(t) + + // Worker 1 reserves foo.go exclusively. + _, err := Reserve(store, "worker-1", []string{"foo.go"}, true, "first", 300) + if err != nil { + t.Fatalf("first Reserve: %v", err) + } + + // Worker 2 tries to reserve foo.go -- should fail. + _, err = Reserve(store, "worker-2", []string{"foo.go"}, true, "second", 300) + if err == nil { + t.Error("expected conflict error for exclusive reservation") + } +} + +func TestReserve_SameAgentNoConflict(t *testing.T) { + store := testStore(t) + + // Same agent can reserve the same path again. + _, err := Reserve(store, "worker-1", []string{"foo.go"}, true, "first", 300) + if err != nil { + t.Fatalf("first Reserve: %v", err) + } + + _, err = Reserve(store, "worker-1", []string{"foo.go"}, true, "second", 300) + if err != nil { + t.Fatalf("same agent should not conflict: %v", err) + } +} + +func TestReserve_NonExclusiveNoConflict(t *testing.T) { + store := testStore(t) + + // Non-exclusive reservation should not block others. + _, err := Reserve(store, "worker-1", []string{"foo.go"}, false, "reading", 300) + if err != nil { + t.Fatalf("first Reserve: %v", err) + } + + _, err = Reserve(store, "worker-2", []string{"foo.go"}, false, "also reading", 300) + if err != nil { + t.Fatalf("non-exclusive should not conflict: %v", err) + } +} + +func TestRelease_ByPath(t *testing.T) { + store := testStore(t) + + Reserve(store, "worker-1", []string{"foo.go"}, true, "test", 300) + + if err := Release(store, []string{"foo.go"}, nil); err != nil { + t.Fatalf("Release: %v", err) + } + + // Should be able to reserve again by another agent. + _, err := Reserve(store, "worker-2", []string{"foo.go"}, true, "after release", 300) + if err != nil { + t.Fatalf("reserve after release: %v", err) + } +} + +func TestRelease_ByID(t *testing.T) { + store := testStore(t) + + reservations, _ := Reserve(store, "worker-1", []string{"foo.go"}, true, "test", 300) + + if err := Release(store, nil, []int{reservations[0].ID}); err != nil { + t.Fatalf("Release by ID: %v", err) + } + + // Should be able to reserve again. + _, err := Reserve(store, "worker-2", []string{"foo.go"}, true, "after release", 300) + if err != nil { + t.Fatalf("reserve after release by ID: %v", err) + } +} + +func TestRelease_NothingSpecified(t *testing.T) { + store := testStore(t) + + err := Release(store, nil, nil) + if err == nil { + t.Error("expected error when nothing specified") + } +} + +func TestReleaseAll(t *testing.T) { + store := testStore(t) + + Reserve(store, "worker-1", []string{"a.go"}, true, "test", 300) + Reserve(store, "worker-2", []string{"b.go"}, true, "test", 300) + + if err := ReleaseAll(store, ""); err != nil { + t.Fatalf("ReleaseAll: %v", err) + } + + // Both should be available now. + _, err := Reserve(store, "worker-3", []string{"a.go", "b.go"}, true, "after release all", 300) + if err != nil { + t.Fatalf("reserve after release all: %v", err) + } +} + +func TestReleaseAgent(t *testing.T) { + store := testStore(t) + + Reserve(store, "worker-1", []string{"a.go", "b.go"}, true, "test", 300) + Reserve(store, "worker-2", []string{"c.go"}, true, "test", 300) + + if err := ReleaseAgent(store, "worker-1"); err != nil { + t.Fatalf("ReleaseAgent: %v", err) + } + + // worker-1's paths should be available. + _, err := Reserve(store, "worker-3", []string{"a.go"}, true, "after agent release", 300) + if err != nil { + t.Fatalf("reserve a.go after agent release: %v", err) + } + + // worker-2's path should still be reserved. + _, err = Reserve(store, "worker-3", []string{"c.go"}, true, "conflict", 300) + if err == nil { + t.Error("expected conflict -- worker-2's reservation should still exist") + } +} diff --git a/internal/tools/hive/tools.go b/internal/tools/hive/tools.go index 8f822c3..92063f2 100644 --- a/internal/tools/hive/tools.go +++ b/internal/tools/hive/tools.go @@ -15,6 +15,13 @@ func Register(reg *registry.Registry, store *db.Store) { reg.Register(hiveCreate(store)) reg.Register(hiveClose(store)) reg.Register(hiveUpdate(store)) + reg.Register(hiveCreateEpic(store)) + reg.Register(hiveQuery(store)) + reg.Register(hiveStart(store)) + reg.Register(hiveReady(store)) + reg.Register(hiveSync(store)) + reg.Register(hiveSessionStart(store)) + reg.Register(hiveSessionEnd(store)) } func hiveCells(store *db.Store) *registry.Tool { @@ -135,3 +142,210 @@ func hiveUpdate(store *db.Store) *registry.Tool { }, } } + +func hiveCreateEpic(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "hive_create_epic", + Description: "Create an epic with subtasks in one atomic operation.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["epic_title", "subtasks"], + "properties": { + "epic_title": {"type": "string"}, + "epic_description": {"type": "string"}, + "subtasks": { + "type": "array", + "items": { + "type": "object", + "required": ["title"], + "properties": { + "title": {"type": "string"}, + "priority": {"type": "number", "minimum": 0, "maximum": 3}, + "files": {"type": "array", "items": {"type": "string"}} + } + } + } + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input hive.CreateEpicInput + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + epic, subtasks, err := hive.CreateEpic(store, input) + if err != nil { + return "", err + } + result := map[string]any{ + "epic": epic, + "subtasks": subtasks, + } + out, err := json.MarshalIndent(result, "", " ") + return string(out), err + }, + } +} + +func hiveQuery(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "hive_query", + Description: "Query cells with filters (alias for hive_cells).", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "id": {"type": "string"}, + "status": {"type": "string", "enum": ["open", "in_progress", "blocked", "closed"]}, + "type": {"type": "string", "enum": ["task", "bug", "feature", "epic", "chore"]}, + "ready": {"type": "boolean"}, + "limit": {"type": "number"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var q hive.CellQuery + if len(args) > 0 { + json.Unmarshal(args, &q) + } + cells, err := hive.QueryCells(store, q) + if err != nil { + return "", err + } + out, err := json.MarshalIndent(cells, "", " ") + return string(out), err + }, + } +} + +func hiveStart(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "hive_start", + Description: "Mark a cell as in-progress.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ID string `json:"id"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + if err := hive.StartCell(store, input.ID); err != nil { + return "", err + } + return `{"status": "in_progress"}`, nil + }, + } +} + +func hiveReady(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "hive_ready", + Description: "Get the next ready cell (unblocked, highest priority).", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": {} + }`), + Execute: func(args json.RawMessage) (string, error) { + cell, err := hive.ReadyCell(store) + if err != nil { + return "", err + } + if cell == nil { + return `{"message": "no ready cells"}`, nil + } + out, err := json.MarshalIndent(cell, "", " ") + return string(out), err + }, + } +} + +func hiveSync(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "hive_sync", + Description: "Sync cells to git and push.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "auto_pull": {"type": "boolean"}, + "project_path": {"type": "string", "description": "Project directory to sync (default: \".\")"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + AutoPull bool `json:"auto_pull,omitempty"` + ProjectPath string `json:"project_path,omitempty"` + } + if len(args) > 0 { + json.Unmarshal(args, &input) + } + projectPath := input.ProjectPath + if projectPath == "" { + projectPath = "." + } + if err := hive.Sync(store, projectPath); err != nil { + return "", err + } + return `{"status": "synced"}`, nil + }, + } +} + +func hiveSessionStart(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "hive_session_start", + Description: "Start a new work session. Returns previous session's handoff notes if available.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "active_cell_id": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ActiveCellID string `json:"active_cell_id,omitempty"` + } + if len(args) > 0 { + json.Unmarshal(args, &input) + } + notes, err := hive.SessionStart(store, input.ActiveCellID) + if err != nil { + return "", err + } + result := map[string]string{ + "status": "started", + "handoff_notes": notes, + } + out, err := json.MarshalIndent(result, "", " ") + return string(out), err + }, + } +} + +func hiveSessionEnd(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "hive_session_end", + Description: "End current session with handoff notes for next session.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "handoff_notes": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + HandoffNotes string `json:"handoff_notes,omitempty"` + } + if len(args) > 0 { + json.Unmarshal(args, &input) + } + if err := hive.SessionEnd(store, input.HandoffNotes); err != nil { + return "", err + } + return `{"status": "ended"}`, nil + }, + } +} diff --git a/internal/tools/memory/tools.go b/internal/tools/memory/tools.go new file mode 100644 index 0000000..af317d0 --- /dev/null +++ b/internal/tools/memory/tools.go @@ -0,0 +1,118 @@ +// Package memory registers MCP tools for semantic memory operations. +// +// Two primary tools (hivemind_store, hivemind_find) proxy to Dewey's semantic +// search. Six secondary tools return deprecation messages pointing to native +// Dewey equivalents. +package memory + +import ( + "encoding/json" + "errors" + + "github.com/unbound-force/replicator/internal/memory" + "github.com/unbound-force/replicator/internal/tools/registry" +) + +// Register adds all memory tools to the registry. +func Register(reg *registry.Registry, client *memory.Client) { + // Primary tools -- proxy to Dewey. + reg.Register(hivemindStore(client)) + reg.Register(hivemindFind(client)) + + // Deprecated tools -- return deprecation messages. + reg.Register(hivemindDeprecated("hivemind_get", "Get specific memory by ID")) + reg.Register(hivemindDeprecated("hivemind_remove", "Delete outdated/incorrect memory")) + reg.Register(hivemindDeprecated("hivemind_validate", "Confirm memory is still accurate")) + reg.Register(hivemindDeprecated("hivemind_stats", "Memory statistics and health check")) + reg.Register(hivemindDeprecated("hivemind_index", "Index AI session directories")) + reg.Register(hivemindDeprecated("hivemind_sync", "Sync learnings to git-backed team sharing")) +} + +func hivemindStore(client *memory.Client) *registry.Tool { + return ®istry.Tool{ + Name: "hivemind_store", + Description: "Store a memory (learnings, decisions, patterns) with metadata and tags. Proxies to Dewey semantic search.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["information"], + "properties": { + "information": {"type": "string"}, + "tags": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + Information string `json:"information"` + Tags string `json:"tags"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + + result, err := client.Store(input.Information, input.Tags) + if err != nil { + var unavail *memory.UnavailableError + if errors.As(err, &unavail) { + return memory.UnavailableResponse(err), nil + } + return "", err + } + + out, err := json.MarshalIndent(result, "", " ") + return string(out), err + }, + } +} + +func hivemindFind(client *memory.Client) *registry.Tool { + return ®istry.Tool{ + Name: "hivemind_find", + Description: "Search all memories by semantic similarity. Proxies to Dewey semantic search.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "collection": {"type": "string"}, + "limit": {"type": "number"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + Query string `json:"query"` + Collection string `json:"collection"` + Limit int `json:"limit"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + + result, err := client.Find(input.Query, input.Collection, input.Limit) + if err != nil { + var unavail *memory.UnavailableError + if errors.As(err, &unavail) { + return memory.UnavailableResponse(err), nil + } + return "", err + } + + out, err := json.MarshalIndent(result, "", " ") + return string(out), err + }, + } +} + +// hivemindDeprecated creates a tool that returns a deprecation message. +func hivemindDeprecated(name, description string) *registry.Tool { + return ®istry.Tool{ + Name: name, + Description: description + " (DEPRECATED)", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": {} + }`), + Execute: func(args json.RawMessage) (string, error) { + return memory.DeprecatedResponse(name), nil + }, + } +} diff --git a/internal/tools/swarm/tools.go b/internal/tools/swarm/tools.go new file mode 100644 index 0000000..08369cd --- /dev/null +++ b/internal/tools/swarm/tools.go @@ -0,0 +1,822 @@ +// Package swarm registers MCP tools for swarm orchestration operations. +package swarm + +import ( + "encoding/json" + + "github.com/unbound-force/replicator/internal/db" + "github.com/unbound-force/replicator/internal/swarm" + "github.com/unbound-force/replicator/internal/tools/registry" +) + +// Register adds all swarm orchestration tools to the registry. +func Register(reg *registry.Registry, store *db.Store) { + // Init & decomposition. + reg.Register(swarmInit(store)) + reg.Register(swarmSelectStrategy()) + reg.Register(swarmPlanPrompt()) + reg.Register(swarmDecompose()) + reg.Register(swarmValidateDecomposition()) + + // Subtask spawning. + reg.Register(swarmSubtaskPrompt()) + reg.Register(swarmSpawnSubtask()) + reg.Register(swarmCompleteSubtask(store)) + + // Progress & status. + reg.Register(swarmProgress(store)) + reg.Register(swarmComplete(store)) + reg.Register(swarmStatus(store)) + reg.Register(swarmRecordOutcome(store)) + + // Worktree management. + reg.Register(swarmWorktreeCreate()) + reg.Register(swarmWorktreeMerge()) + reg.Register(swarmWorktreeCleanup()) + reg.Register(swarmWorktreeList()) + + // Review & broadcast. + reg.Register(swarmReview()) + reg.Register(swarmReviewFeedback(store)) + reg.Register(swarmAdversarialReview()) + reg.Register(swarmEvaluationPrompt()) + reg.Register(swarmBroadcast(store)) + + // Insights. + reg.Register(swarmGetStrategyInsights(store)) + reg.Register(swarmGetFileInsights(store)) + reg.Register(swarmGetPatternInsights(store)) +} + +// --- Init & Decomposition --- + +func swarmInit(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_init", + Description: "Initialize swarm session and check tool availability.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "project_path": {"type": "string"}, + "isolation": {"type": "string", "enum": ["worktree", "reservation"]} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ProjectPath string `json:"project_path"` + Isolation string `json:"isolation"` + } + if len(args) > 0 { + json.Unmarshal(args, &input) + } + result, err := swarm.Init(store, input.ProjectPath, input.Isolation) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmSelectStrategy() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_select_strategy", + Description: "Analyze task and recommend decomposition strategy.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["task"], + "properties": { + "task": {"type": "string", "minLength": 1}, + "codebase_context": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + Task string `json:"task"` + CodebaseContext string `json:"codebase_context"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + strategy, err := swarm.SelectStrategy(input.Task, input.CodebaseContext) + if err != nil { + return "", err + } + result := map[string]string{"strategy": strategy} + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmPlanPrompt() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_plan_prompt", + Description: "Generate strategy-specific decomposition prompt.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["task"], + "properties": { + "task": {"type": "string", "minLength": 1}, + "strategy": {"type": "string", "enum": ["file-based", "feature-based", "risk-based", "auto"]}, + "context": {"type": "string"}, + "max_subtasks": {"type": "integer", "minimum": 2, "maximum": 10} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + Task string `json:"task"` + Strategy string `json:"strategy"` + Context string `json:"context"` + MaxSubtasks int `json:"max_subtasks"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + prompt := swarm.PlanPrompt(input.Task, input.Strategy, input.Context, input.MaxSubtasks) + return prompt, nil + }, + } +} + +func swarmDecompose() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_decompose", + Description: "Generate decomposition prompt for breaking task into subtasks.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["task"], + "properties": { + "task": {"type": "string", "minLength": 1}, + "context": {"type": "string"}, + "max_subtasks": {"type": "integer", "minimum": 2, "maximum": 10} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + Task string `json:"task"` + Context string `json:"context"` + MaxSubtasks int `json:"max_subtasks"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + prompt := swarm.Decompose(input.Task, input.Context, input.MaxSubtasks) + return prompt, nil + }, + } +} + +func swarmValidateDecomposition() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_validate_decomposition", + Description: "Validate a decomposition response against CellTreeSchema.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["response"], + "properties": { + "response": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + Response string `json:"response"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.ValidateDecomposition(input.Response) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +// --- Subtask Spawning --- + +func swarmSubtaskPrompt() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_subtask_prompt", + Description: "Generate the prompt for a spawned subtask agent.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["agent_name", "bead_id", "epic_id", "subtask_title", "files"], + "properties": { + "agent_name": {"type": "string"}, + "bead_id": {"type": "string"}, + "epic_id": {"type": "string"}, + "subtask_title": {"type": "string"}, + "files": {"type": "array", "items": {"type": "string"}}, + "shared_context": {"type": "string"}, + "subtask_description": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + AgentName string `json:"agent_name"` + BeadID string `json:"bead_id"` + EpicID string `json:"epic_id"` + SubtaskTitle string `json:"subtask_title"` + Files []string `json:"files"` + SharedCtx string `json:"shared_context"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + prompt := swarm.SubtaskPrompt(input.AgentName, input.BeadID, input.EpicID, input.SubtaskTitle, input.Files, input.SharedCtx) + return prompt, nil + }, + } +} + +func swarmSpawnSubtask() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_spawn_subtask", + Description: "Prepare a subtask for spawning with Task tool.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["bead_id", "epic_id", "subtask_title", "files"], + "properties": { + "bead_id": {"type": "string"}, + "epic_id": {"type": "string"}, + "subtask_title": {"type": "string"}, + "files": {"type": "array", "items": {"type": "string"}}, + "subtask_description": {"type": "string"}, + "shared_context": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + BeadID string `json:"bead_id"` + EpicID string `json:"epic_id"` + Title string `json:"subtask_title"` + Files []string `json:"files"` + Description string `json:"subtask_description"` + SharedCtx string `json:"shared_context"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.SpawnSubtask(input.BeadID, input.EpicID, input.Title, input.Files, input.Description, input.SharedCtx) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmCompleteSubtask(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_complete_subtask", + Description: "Handle subtask completion after Task agent returns.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["bead_id", "task_result"], + "properties": { + "bead_id": {"type": "string"}, + "task_result": {"type": "string"}, + "files_touched": {"type": "array", "items": {"type": "string"}} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + BeadID string `json:"bead_id"` + TaskResult string `json:"task_result"` + FilesTouched []string `json:"files_touched"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.CompleteSubtask(store, input.BeadID, input.TaskResult, input.FilesTouched) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +// --- Progress & Status --- + +func swarmProgress(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_progress", + Description: "Report progress on a subtask to coordinator.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["project_key", "agent_name", "bead_id", "status"], + "properties": { + "project_key": {"type": "string"}, + "agent_name": {"type": "string"}, + "bead_id": {"type": "string"}, + "status": {"type": "string", "enum": ["in_progress", "blocked", "completed", "failed"]}, + "progress_percent": {"type": "number", "minimum": 0, "maximum": 100}, + "message": {"type": "string"}, + "files_touched": {"type": "array", "items": {"type": "string"}} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ProjectKey string `json:"project_key"` + AgentName string `json:"agent_name"` + BeadID string `json:"bead_id"` + Status string `json:"status"` + ProgressPercent int `json:"progress_percent"` + Message string `json:"message"` + FilesTouched []string `json:"files_touched"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + err := swarm.Progress(store, input.ProjectKey, input.AgentName, input.BeadID, input.Status, input.ProgressPercent, input.Message, input.FilesTouched) + if err != nil { + return "", err + } + return `{"status": "recorded"}`, nil + }, + } +} + +func swarmComplete(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_complete", + Description: "Mark subtask complete with Verification Gate.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["project_key", "agent_name", "bead_id", "summary"], + "properties": { + "project_key": {"type": "string"}, + "agent_name": {"type": "string"}, + "bead_id": {"type": "string"}, + "summary": {"type": "string"}, + "files_touched": {"type": "array", "items": {"type": "string"}}, + "evaluation": {"type": "string"}, + "skip_verification": {"type": "boolean"}, + "skip_review": {"type": "boolean"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ProjectKey string `json:"project_key"` + AgentName string `json:"agent_name"` + BeadID string `json:"bead_id"` + Summary string `json:"summary"` + FilesTouched []string `json:"files_touched"` + Evaluation string `json:"evaluation"` + SkipVerification bool `json:"skip_verification"` + SkipReview bool `json:"skip_review"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.Complete(store, input.ProjectKey, input.AgentName, input.BeadID, input.Summary, input.FilesTouched, input.Evaluation, input.SkipVerification, input.SkipReview) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmStatus(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_status", + Description: "Get status of a swarm by epic ID.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["epic_id", "project_key"], + "properties": { + "epic_id": {"type": "string"}, + "project_key": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + EpicID string `json:"epic_id"` + ProjectKey string `json:"project_key"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.Status(store, input.EpicID, input.ProjectKey) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmRecordOutcome(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_record_outcome", + Description: "Record subtask outcome for implicit feedback scoring.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["bead_id", "duration_ms", "success"], + "properties": { + "bead_id": {"type": "string"}, + "duration_ms": {"type": "integer", "minimum": 0}, + "success": {"type": "boolean"}, + "strategy": {"type": "string", "enum": ["file-based", "feature-based", "risk-based"]}, + "files_touched": {"type": "array", "items": {"type": "string"}}, + "error_count": {"type": "integer", "minimum": 0}, + "retry_count": {"type": "integer", "minimum": 0}, + "criteria": {"type": "array", "items": {"type": "string"}} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + BeadID string `json:"bead_id"` + DurationMs int `json:"duration_ms"` + Success bool `json:"success"` + Strategy string `json:"strategy"` + FilesTouched []string `json:"files_touched"` + ErrorCount int `json:"error_count"` + RetryCount int `json:"retry_count"` + Criteria []string `json:"criteria"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + err := swarm.RecordOutcome(store, input.BeadID, input.DurationMs, input.Success, input.Strategy, input.FilesTouched, input.ErrorCount, input.RetryCount, input.Criteria) + if err != nil { + return "", err + } + return `{"status": "recorded"}`, nil + }, + } +} + +// --- Worktree Management --- + +func swarmWorktreeCreate() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_worktree_create", + Description: "Create a git worktree for isolated task execution.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["project_path", "task_id", "start_commit"], + "properties": { + "project_path": {"type": "string"}, + "task_id": {"type": "string"}, + "start_commit": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ProjectPath string `json:"project_path"` + TaskID string `json:"task_id"` + StartCommit string `json:"start_commit"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.WorktreeCreate(input.ProjectPath, input.TaskID, input.StartCommit) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmWorktreeMerge() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_worktree_merge", + Description: "Cherry-pick commits from worktree back to main branch.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["project_path", "task_id"], + "properties": { + "project_path": {"type": "string"}, + "task_id": {"type": "string"}, + "start_commit": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ProjectPath string `json:"project_path"` + TaskID string `json:"task_id"` + StartCommit string `json:"start_commit"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.WorktreeMerge(input.ProjectPath, input.TaskID, input.StartCommit) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmWorktreeCleanup() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_worktree_cleanup", + Description: "Remove a worktree after completion or abort. Idempotent.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["project_path"], + "properties": { + "project_path": {"type": "string"}, + "task_id": {"type": "string"}, + "cleanup_all": {"type": "boolean"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ProjectPath string `json:"project_path"` + TaskID string `json:"task_id"` + CleanupAll bool `json:"cleanup_all"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.WorktreeCleanup(input.ProjectPath, input.TaskID, input.CleanupAll) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmWorktreeList() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_worktree_list", + Description: "List all active worktrees for a project.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["project_path"], + "properties": { + "project_path": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ProjectPath string `json:"project_path"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.WorktreeList(input.ProjectPath) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +// --- Review & Broadcast --- + +func swarmReview() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_review", + Description: "Generate a review prompt for a completed subtask.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["project_key", "epic_id", "task_id"], + "properties": { + "project_key": {"type": "string"}, + "epic_id": {"type": "string"}, + "task_id": {"type": "string"}, + "files_touched": {"type": "array", "items": {"type": "string"}} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ProjectKey string `json:"project_key"` + EpicID string `json:"epic_id"` + TaskID string `json:"task_id"` + FilesTouched []string `json:"files_touched"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + prompt := swarm.Review(input.ProjectKey, input.EpicID, input.TaskID, input.FilesTouched) + return prompt, nil + }, + } +} + +func swarmReviewFeedback(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_review_feedback", + Description: "Send review feedback to a worker. Tracks attempts (max 3).", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["project_key", "task_id", "worker_id", "status"], + "properties": { + "project_key": {"type": "string"}, + "task_id": {"type": "string"}, + "worker_id": {"type": "string"}, + "status": {"type": "string", "enum": ["approved", "needs_changes"]}, + "issues": {"type": "string"}, + "summary": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ProjectKey string `json:"project_key"` + TaskID string `json:"task_id"` + WorkerID string `json:"worker_id"` + Status string `json:"status"` + Issues string `json:"issues"` + Summary string `json:"summary"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.ReviewFeedback(store, input.ProjectKey, input.TaskID, input.WorkerID, input.Status, input.Issues, input.Summary) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmAdversarialReview() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_adversarial_review", + Description: "VDD-style adversarial code review using hostile, fresh-context agent.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["diff"], + "properties": { + "diff": {"type": "string"}, + "test_output": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + Diff string `json:"diff"` + TestOutput string `json:"test_output"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + prompt := swarm.AdversarialReview(input.Diff, input.TestOutput) + return prompt, nil + }, + } +} + +func swarmEvaluationPrompt() *registry.Tool { + return ®istry.Tool{ + Name: "swarm_evaluation_prompt", + Description: "Generate self-evaluation prompt for a completed subtask.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["bead_id", "subtask_title", "files_touched"], + "properties": { + "bead_id": {"type": "string"}, + "subtask_title": {"type": "string"}, + "files_touched": {"type": "array", "items": {"type": "string"}} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + BeadID string `json:"bead_id"` + SubtaskTitle string `json:"subtask_title"` + FilesTouched []string `json:"files_touched"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + prompt := swarm.EvaluationPrompt(input.BeadID, input.SubtaskTitle, input.FilesTouched) + return prompt, nil + }, + } +} + +func swarmBroadcast(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_broadcast", + Description: "Broadcast context update to all agents working on the same epic.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["project_path", "agent_name", "epic_id", "message"], + "properties": { + "project_path": {"type": "string"}, + "agent_name": {"type": "string"}, + "epic_id": {"type": "string"}, + "message": {"type": "string"}, + "importance": {"type": "string", "enum": ["info", "warning", "blocker"]}, + "files_affected": {"type": "array", "items": {"type": "string"}} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + ProjectPath string `json:"project_path"` + AgentName string `json:"agent_name"` + EpicID string `json:"epic_id"` + Message string `json:"message"` + Importance string `json:"importance"` + FilesAffected []string `json:"files_affected"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + err := swarm.Broadcast(store, input.ProjectPath, input.AgentName, input.EpicID, input.Message, input.Importance, input.FilesAffected) + if err != nil { + return "", err + } + return `{"status": "broadcast_sent"}`, nil + }, + } +} + +// --- Insights --- + +func swarmGetStrategyInsights(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_get_strategy_insights", + Description: "Get strategy success rates for decomposition planning.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["task"], + "properties": { + "task": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + Task string `json:"task"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.GetStrategyInsights(store, input.Task) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmGetFileInsights(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_get_file_insights", + Description: "Get file-specific gotchas for worker context.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["files"], + "properties": { + "files": {"type": "array", "items": {"type": "string"}} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + Files []string `json:"files"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + result, err := swarm.GetFileInsights(store, input.Files) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} + +func swarmGetPatternInsights(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarm_get_pattern_insights", + Description: "Get common failure patterns across swarms.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": {} + }`), + Execute: func(args json.RawMessage) (string, error) { + result, err := swarm.GetPatternInsights(store) + if err != nil { + return "", err + } + out, _ := json.MarshalIndent(result, "", " ") + return string(out), nil + }, + } +} diff --git a/internal/tools/swarmmail/tools.go b/internal/tools/swarmmail/tools.go new file mode 100644 index 0000000..421872b --- /dev/null +++ b/internal/tools/swarmmail/tools.go @@ -0,0 +1,321 @@ +// Package swarmmail registers MCP tools for swarm mail operations. +package swarmmail + +import ( + "encoding/json" + "fmt" + + "github.com/unbound-force/replicator/internal/db" + "github.com/unbound-force/replicator/internal/swarmmail" + "github.com/unbound-force/replicator/internal/tools/registry" +) + +// Register adds all swarm mail tools to the registry. +func Register(reg *registry.Registry, store *db.Store) { + reg.Register(swarmmailInit(store)) + reg.Register(swarmmailSend(store)) + reg.Register(swarmmailInbox(store)) + reg.Register(swarmmailReadMessage(store)) + reg.Register(swarmmailReserve(store)) + reg.Register(swarmmailRelease(store)) + reg.Register(swarmmailReleaseAll(store)) + reg.Register(swarmmailReleaseAgent(store)) + reg.Register(swarmmailAck(store)) + reg.Register(swarmmailHealth(store)) +} + +func swarmmailInit(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarmmail_init", + Description: "Initialize swarm mail session. Registers the agent.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["project_path"], + "properties": { + "agent_name": {"type": "string"}, + "project_path": {"type": "string"}, + "task_description": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + AgentName string `json:"agent_name"` + ProjectPath string `json:"project_path"` + TaskDescription string `json:"task_description"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + agentName := input.AgentName + if agentName == "" { + agentName = "default" + } + agent, err := swarmmail.Init(store, agentName, input.ProjectPath, input.TaskDescription) + if err != nil { + return "", err + } + out, err := json.MarshalIndent(agent, "", " ") + return string(out), err + }, + } +} + +func swarmmailSend(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarmmail_send", + Description: "Send a message to other agents via swarm mail.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["to", "subject", "body"], + "properties": { + "to": {"type": "array", "items": {"type": "string"}}, + "subject": {"type": "string"}, + "body": {"type": "string"}, + "importance": {"type": "string", "enum": ["low", "normal", "high", "urgent"]}, + "thread_id": {"type": "string"}, + "ack_required": {"type": "boolean"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + From string `json:"from"` + swarmmail.SendInput + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + fromAgent := input.From + if fromAgent == "" { + fromAgent = "unknown" + } + if err := swarmmail.Send(store, fromAgent, input.SendInput); err != nil { + return "", err + } + return `{"status": "sent"}`, nil + }, + } +} + +func swarmmailInbox(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarmmail_inbox", + Description: "Fetch inbox (max 5 messages, bodies excluded).", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "limit": {"type": "number", "maximum": 5}, + "urgent_only": {"type": "boolean"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + AgentName string `json:"agent_name"` + Limit int `json:"limit"` + UrgentOnly bool `json:"urgent_only"` + } + if len(args) > 0 { + json.Unmarshal(args, &input) + } + agentName := input.AgentName + if agentName == "" { + agentName = "default" + } + summaries, err := swarmmail.Inbox(store, agentName, input.Limit, input.UrgentOnly) + if err != nil { + return "", err + } + out, err := json.MarshalIndent(summaries, "", " ") + return string(out), err + }, + } +} + +func swarmmailReadMessage(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarmmail_read_message", + Description: "Fetch one message body by ID.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["message_id"], + "properties": { + "message_id": {"type": "number"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + MessageID int `json:"message_id"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + msg, err := swarmmail.ReadMessage(store, input.MessageID) + if err != nil { + return "", err + } + out, err := json.MarshalIndent(msg, "", " ") + return string(out), err + }, + } +} + +func swarmmailReserve(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarmmail_reserve", + Description: "Reserve file paths for exclusive editing.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["paths"], + "properties": { + "paths": {"type": "array", "items": {"type": "string"}}, + "exclusive": {"type": "boolean"}, + "reason": {"type": "string"}, + "ttl_seconds": {"type": "number"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + AgentName string `json:"agent_name"` + Paths []string `json:"paths"` + Exclusive bool `json:"exclusive"` + Reason string `json:"reason"` + TTLSeconds int `json:"ttl_seconds"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + agentName := input.AgentName + if agentName == "" { + agentName = "default" + } + reservations, err := swarmmail.Reserve(store, agentName, input.Paths, input.Exclusive, input.Reason, input.TTLSeconds) + if err != nil { + return "", err + } + out, err := json.MarshalIndent(reservations, "", " ") + return string(out), err + }, + } +} + +func swarmmailRelease(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarmmail_release", + Description: "Release file reservations.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "paths": {"type": "array", "items": {"type": "string"}}, + "reservation_ids": {"type": "array", "items": {"type": "number"}} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + Paths []string `json:"paths"` + ReservationIDs []int `json:"reservation_ids"` + } + if len(args) > 0 { + json.Unmarshal(args, &input) + } + if err := swarmmail.Release(store, input.Paths, input.ReservationIDs); err != nil { + return "", err + } + return `{"status": "released"}`, nil + }, + } +} + +func swarmmailReleaseAll(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarmmail_release_all", + Description: "Release all file reservations in the project.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": {} + }`), + Execute: func(args json.RawMessage) (string, error) { + if err := swarmmail.ReleaseAll(store, ""); err != nil { + return "", err + } + return `{"status": "all_released"}`, nil + }, + } +} + +func swarmmailReleaseAgent(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarmmail_release_agent", + Description: "Release all file reservations for a specific agent.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["agent_name"], + "properties": { + "agent_name": {"type": "string"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + AgentName string `json:"agent_name"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + if err := swarmmail.ReleaseAgent(store, input.AgentName); err != nil { + return "", err + } + return fmt.Sprintf(`{"status": "released", "agent": %q}`, input.AgentName), nil + }, + } +} + +func swarmmailAck(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarmmail_ack", + Description: "Acknowledge a message.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["message_id"], + "properties": { + "message_id": {"type": "number"} + } + }`), + Execute: func(args json.RawMessage) (string, error) { + var input struct { + MessageID int `json:"message_id"` + } + if err := json.Unmarshal(args, &input); err != nil { + return "", err + } + if err := swarmmail.Ack(store, input.MessageID); err != nil { + return "", err + } + return `{"status": "acknowledged"}`, nil + }, + } +} + +func swarmmailHealth(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "swarmmail_health", + Description: "Check swarm mail database health.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": {} + }`), + Execute: func(args json.RawMessage) (string, error) { + // Verify the database is accessible by counting key tables. + var agentCount, msgCount, resCount int + store.DB.QueryRow("SELECT COUNT(*) FROM agents").Scan(&agentCount) + store.DB.QueryRow("SELECT COUNT(*) FROM messages").Scan(&msgCount) + store.DB.QueryRow("SELECT COUNT(*) FROM reservations").Scan(&resCount) + + result := map[string]any{ + "status": "healthy", + "agents": agentCount, + "messages": msgCount, + "reservations": resCount, + } + out, err := json.MarshalIndent(result, "", " ") + return string(out), err + }, + } +} diff --git a/specs/001-go-rewrite-phases/checklists/requirements.md b/specs/001-go-rewrite-phases/checklists/requirements.md new file mode 100644 index 0000000..55fcbd7 --- /dev/null +++ b/specs/001-go-rewrite-phases/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Go Rewrite -- Remaining Phases + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-04 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. +- The spec covers 5 implementation phases (hive+mail, orchestration, memory, CLI, parity) across 6 user stories. +- 23 functional requirements trace to 6 user stories and 8 success criteria. +- Scope explicitly excludes: eval system (stays TypeScript), dashboard web UI (stays TypeScript), swarm-queue (deferred). +- The spec intentionally refers to "the binary" and "the system" rather than naming Go or specific libraries, keeping it technology-agnostic for the requirements layer. diff --git a/specs/001-go-rewrite-phases/plan.md b/specs/001-go-rewrite-phases/plan.md new file mode 100644 index 0000000..663bd8b --- /dev/null +++ b/specs/001-go-rewrite-phases/plan.md @@ -0,0 +1,499 @@ +# Implementation Plan: Go Rewrite — Remaining Phases + +**Branch**: `001-go-rewrite-phases` | **Date**: 2026-04-04 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/001-go-rewrite-phases/spec.md` + +## Summary + +Complete the Go rewrite of cyborg-swarm across 5 phases: finish the hive tool suite and add swarm mail (Phase 1), implement swarm orchestration with git worktree isolation (Phase 2), add memory/context tools proxied through Dewey (Phase 3), build CLI commands for observability (Phase 4), and verify parity against the TypeScript version (Phase 5). The existing Phase 0 scaffold provides the MCP server, SQLite database, tool registry, and 4 hive tools — all remaining work extends these established patterns. + +## Technical Context + +**Language/Version**: Go 1.25+ +**Primary Dependencies**: `cobra` (CLI), `modernc.org/sqlite` (pure Go SQLite), stdlib `encoding/json` (MCP JSON-RPC), stdlib `os/exec` (git operations) +**Storage**: SQLite at `~/.config/swarm-tools/swarm.db` (WAL mode, compatible with cyborg-swarm) +**Testing**: `go test` (stdlib only, no testify — per TC-001) +**Target Platform**: macOS/Linux arm64+amd64, Windows amd64 (via goreleaser) +**Project Type**: CLI + MCP server (single binary) +**Performance Goals**: <50ms cold start to first MCP response (SC-002), <20MB binary (SC-004) +**Constraints**: Zero CGo, zero runtime dependencies, single-file distribution +**Scale/Scope**: 70 unique MCP tools, 6 CLI commands, ~80% line coverage target + +## Constitution Check + +*No `.specify/memory/constitution.md` found in this repo. Applying universal principles from AGENTS.md:* + +- **TDD Everything**: All code follows Red-Green-Refactor. Tests use `db.OpenMemory()` for isolation. ✅ +- **Single Binary**: No CGo, pure Go SQLite via `modernc.org/sqlite`. ✅ +- **Schema Compatibility**: Database schema matches cyborg-swarm's libSQL tables. ✅ +- **Convention Pack**: Go pack loaded from `.opencode/unbound/packs/go.md`. All `[MUST]` rules apply. ✅ + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-go-rewrite-phases/ +├── plan.md # This file +├── research.md # Library decisions and technical research +├── quickstart.md # Build, test, and run commands +├── checklists/ +│ └── requirements.md # Spec quality checklist (complete) +└── tasks.md # Task breakdown (created by /speckit.tasks) +``` + +### Source Code (repository root) + +```text +cmd/replicator/ # CLI entrypoint (cobra commands) +├── main.go # Root command, subcommand registration +├── serve.go # MCP server startup +├── cells.go # cells command (exists) +├── doctor.go # Phase 4: health checks +├── stats.go # Phase 4: activity metrics +├── query.go # Phase 4: SQL analytics +└── setup.go # Phase 4: environment setup + +internal/ +├── config/ # Configuration (exists) +│ └── config.go +├── db/ # SQLite + migrations (exists) +│ ├── db.go +│ ├── db_test.go +│ └── migrations.go # Extended with swarmmail/session tables +├── hive/ # Cell domain logic (exists, extended) +│ ├── cells.go # Existing: Create, Query, Close, Update +│ ├── cells_test.go +│ ├── epic.go # Phase 1: CreateEpic (atomic epic+subtasks) +│ ├── epic_test.go +│ ├── session.go # Phase 1: SessionStart, SessionEnd +│ ├── session_test.go +│ ├── sync.go # Phase 1: Sync to git +│ └── sync_test.go +├── mcp/ # MCP JSON-RPC server (exists) +│ ├── server.go +│ └── server_test.go +├── swarmmail/ # Phase 1: Agent messaging +│ ├── agent.go # Agent registration, init +│ ├── agent_test.go +│ ├── message.go # Send, inbox, read, ack +│ ├── message_test.go +│ ├── reservation.go # File reservations (lock/unlock) +│ └── reservation_test.go +├── swarm/ # Phase 2: Orchestration +│ ├── decompose.go # Task decomposition prompts +│ ├── decompose_test.go +│ ├── worktree.go # Git worktree create/merge/cleanup/list +│ ├── worktree_test.go +│ ├── progress.go # Progress tracking, status, completion +│ ├── progress_test.go +│ ├── review.go # Review prompts, feedback +│ ├── review_test.go +│ ├── spawn.go # Subtask prompt generation, spawning +│ └── spawn_test.go +├── memory/ # Phase 3: Dewey proxy +│ ├── proxy.go # HTTP client to Dewey MCP endpoint +│ ├── proxy_test.go +│ ├── deprecated.go # Deprecation stubs for secondary tools +│ └── deprecated_test.go +├── doctor/ # Phase 4: Health checks +│ ├── checks.go # Individual check functions +│ └── checks_test.go +├── stats/ # Phase 4: Activity metrics +│ ├── stats.go +│ └── stats_test.go +├── query/ # Phase 4: SQL analytics presets +│ ├── presets.go +│ └── presets_test.go +├── gitutil/ # Shared git helpers (exec wrappers) +│ ├── git.go +│ └── git_test.go +└── tools/ # MCP tool registrations (exists) + ├── registry/ + │ └── registry.go + ├── hive/ + │ └── tools.go # Existing 4 tools + 7 new hive tools + ├── swarmmail/ # Phase 1: 9 swarmmail tools + │ └── tools.go + ├── swarm/ # Phase 2: 16 swarm tools + │ └── tools.go + └── memory/ # Phase 3: 8 memory tools + └── tools.go + +test/ # Phase 5: Parity testing +├── parity/ +│ ├── parity_test.go # Shape comparison harness +│ └── fixtures/ # Captured TypeScript responses +└── README.md +``` + +**Structure Decision**: Follows the existing Go project layout established in Phase 0. Domain logic lives in `internal/{domain}/` with corresponding tool registrations in `internal/tools/{domain}/`. Each domain package owns its types, business logic, and tests. The `cmd/replicator/` layer delegates to domain packages via `Run()` functions per AP-002. No new top-level directories are introduced except `test/` for cross-cutting parity tests in Phase 5. + +--- + +## Phase 1: Hive Completion + Swarm Mail + +**User Stories**: US1 (Complete Hive Tool Suite), US2 (Agent Messaging) +**Requirements**: FR-001 through FR-007, FR-020, FR-022 +**Estimated Effort**: 2-3 weeks + +### 1.1 Remaining Hive Tools (7 tools) + +Complete the hive tool suite by adding the 7 remaining tools to match the TypeScript version. + +| Tool | Domain Function | Package | Notes | +|------|----------------|---------|-------| +| `hive_create_epic` | `hive.CreateEpic()` | `internal/hive/epic.go` | Atomic: create epic + N subtasks in one transaction | +| `hive_query` | `hive.QueryCells()` | existing | Already implemented as `hive_cells` — verify schema parity, may need alias | +| `hive_start` | `hive.StartCell()` | `internal/hive/cells.go` | Set status=in_progress, record timestamp | +| `hive_ready` | `hive.ReadyCell()` | `internal/hive/cells.go` | Return highest-priority unblocked cell | +| `hive_sync` | `hive.Sync()` | `internal/hive/sync.go` | Serialize cells to `.hive/`, git add+commit | +| `hive_session_start` | `hive.SessionStart()` | `internal/hive/session.go` | Create session, return previous handoff notes | +| `hive_session_end` | `hive.SessionEnd()` | `internal/hive/session.go` | Save handoff notes for next session | + +**Implementation approach**: +- `hive_create_epic`: Use `sql.Tx` for atomicity — insert epic row, then insert all subtask rows with `parent_id` pointing to the epic. Roll back on any failure. +- `hive_start`: Simple status update with timestamp. Reuse `UpdateCell` pattern. +- `hive_ready`: Query cells where `status='open'` and no parent cell is still open (unblocked). Order by `priority DESC`. Return first match. +- `hive_sync`: Shell out to `git` via `internal/gitutil/` to add and commit `.hive/` directory contents. +- `hive_session_start/end`: New `sessions` table in SQLite. Store handoff notes as JSON. Return previous session's notes on start. + +**Database changes**: +- Add `sessions` table migration (session_id, agent_name, started_at, ended_at, handoff_notes, active_cell_id) + +**Testing strategy**: +- Unit tests for each domain function using `db.OpenMemory()` +- `TestCreateEpic_AtomicRollback`: Verify that if subtask 3 of 5 fails, no rows are committed +- `TestReadyCell_UnblockedOnly`: Create parent+child cells, verify only unblocked cells are returned +- `TestSync_CommitsToGit`: Use `t.TempDir()` with a git repo, verify commit is created +- `TestSessionStart_ReturnsHandoff`: Create a session with handoff notes, start a new session, verify notes are returned + +### 1.2 Swarm Mail (9 tools) + +Implement the agent messaging and file reservation system. + +| Tool | Domain Function | Package | Notes | +|------|----------------|---------|-------| +| `swarmmail_init` | `swarmmail.Init()` | `internal/swarmmail/agent.go` | Register agent, create session | +| `swarmmail_send` | `swarmmail.Send()` | `internal/swarmmail/message.go` | Persist message to SQLite | +| `swarmmail_inbox` | `swarmmail.Inbox()` | `internal/swarmmail/message.go` | Fetch messages (bodies excluded, max 5) | +| `swarmmail_read_message` | `swarmmail.ReadMessage()` | `internal/swarmmail/message.go` | Fetch one message body by ID | +| `swarmmail_ack` | `swarmmail.Ack()` | `internal/swarmmail/message.go` | Mark message as acknowledged | +| `swarmmail_reserve` | `swarmmail.Reserve()` | `internal/swarmmail/reservation.go` | Lock file paths for exclusive editing | +| `swarmmail_release` | `swarmmail.Release()` | `internal/swarmmail/reservation.go` | Release file reservations | +| `swarmmail_release_all` | `swarmmail.ReleaseAll()` | `internal/swarmmail/reservation.go` | Coordinator override: release all | +| `swarmmail_release_agent` | `swarmmail.ReleaseAgent()` | `internal/swarmmail/reservation.go` | Release all reservations for an agent | + +**Database changes**: +- Add `messages` table migration (id, from_agent, to_agents JSON, subject, body, importance, thread_id, ack_required, acknowledged, created_at) +- Add `reservations` table migration (id, agent_name, path, exclusive, reason, ttl_seconds, created_at, expires_at) + +**Implementation approach**: +- Messages: Standard CRUD with SQLite. `swarmmail_inbox` returns headers only (no body) for context efficiency. `swarmmail_read_message` fetches the full body. +- Reservations: `swarmmail_reserve` checks for existing exclusive reservations on the same path before inserting. Uses a transaction to prevent race conditions. Expired reservations (past TTL) are treated as released. Expiry is checked lazily — expired reservations are filtered out during `Reserve` conflict checks, not cleaned up proactively. +- Agent init: Upsert into `agents` table (existing schema in `internal/db/migrations.go` — columns `name`, `project_path`, `task_description`, `last_seen_at` map directly to `swarmmail_init` parameters), update `last_seen_at`. + +**Testing strategy**: +- `TestReserve_ExclusiveConflict`: Agent A reserves a file, Agent B's reserve attempt fails with conflict error +- `TestReserve_TTLExpiry`: Reserve with short TTL, wait, verify re-reservation succeeds +- `TestInbox_ExcludesBodies`: Send a message, verify inbox response has no body field +- `TestSend_PersistsAcrossRestart`: Send message, close store, reopen, verify message is still there + +### 1.3 Phase 1 Checkpoint + +**Gate**: All 20 tools (4 existing + 7 hive + 9 swarmmail) pass tests. `make check` succeeds. + +**Verification**: +```bash +go test ./... -count=1 -race +go vet ./... +``` + +**Expected test count**: ~50 tests (16 existing + ~34 new) + +--- + +## Phase 2: Swarm Orchestration + +**User Story**: US3 (Swarm Orchestration) +**Requirements**: FR-008 through FR-011 +**Estimated Effort**: 3-4 weeks + +### 2.1 Git Utilities + +Before implementing orchestration tools, establish a shared `internal/gitutil/` package for git operations. + +| Function | Description | +|----------|-------------| +| `gitutil.Run(dir string, args ...string) (string, error)` | Execute git command in directory, return stdout | +| `gitutil.WorktreeAdd(projectPath, worktreePath, branch, startCommit string) error` | `git worktree add` | +| `gitutil.WorktreeRemove(worktreePath string) error` | `git worktree remove` | +| `gitutil.WorktreeList(projectPath string) ([]Worktree, error)` | `git worktree list --porcelain` | +| `gitutil.CherryPick(projectPath, startCommit string) error` | Cherry-pick commits from worktree branch | +| `gitutil.CurrentCommit(projectPath string) (string, error)` | `git rev-parse HEAD` | + +**Implementation approach**: +- Shell out to `git` binary via `os/exec`. This is the spec's stated approach (see Assumptions). +- All functions accept a directory path — no global state. +- Error messages include the git stderr output for debugging. + +**Testing strategy**: +- Tests create real git repos in `t.TempDir()` using `git init`. +- `TestWorktreeAdd_CreatesDirectory`: Create a worktree, verify the directory exists and has the correct branch. +- `TestCherryPick_AppliesCommits`: Create a worktree, make commits, cherry-pick back, verify commits appear on main. +- `TestCherryPick_DetectsConflict`: Create conflicting changes, verify cherry-pick returns an error listing conflicting files. +- Guard with `testing.Short()` per TC-011 since these spawn subprocesses. + +### 2.2 Orchestration Tools (16 tools) + +| Tool | Domain Function | Package | Notes | +|------|----------------|---------|-------| +| `swarm_init` | `swarm.Init()` | `internal/swarm/` | Initialize swarm session | +| `swarm_select_strategy` | `swarm.SelectStrategy()` | `internal/swarm/decompose.go` | Recommend decomposition strategy | +| `swarm_plan_prompt` | `swarm.PlanPrompt()` | `internal/swarm/decompose.go` | Generate strategy-specific prompt | +| `swarm_decompose` | `swarm.Decompose()` | `internal/swarm/decompose.go` | Generate decomposition prompt | +| `swarm_validate_decomposition` | `swarm.ValidateDecomposition()` | `internal/swarm/decompose.go` | Validate response against schema | +| `swarm_subtask_prompt` | `swarm.SubtaskPrompt()` | `internal/swarm/spawn.go` | Generate prompt for spawned agent | +| `swarm_spawn_subtask` | `swarm.SpawnSubtask()` | `internal/swarm/spawn.go` | Prepare subtask for Task tool | +| `swarm_complete_subtask` | `swarm.CompleteSubtask()` | `internal/swarm/spawn.go` | Handle subtask completion | +| `swarm_progress` | `swarm.Progress()` | `internal/swarm/progress.go` | Report progress on subtask | +| `swarm_complete` | `swarm.Complete()` | `internal/swarm/progress.go` | Mark subtask complete with verification | +| `swarm_status` | `swarm.Status()` | `internal/swarm/progress.go` | Get swarm status by epic ID | +| `swarm_record_outcome` | `swarm.RecordOutcome()` | `internal/swarm/progress.go` | Record outcome for feedback scoring | +| `swarm_worktree_create` | `swarm.WorktreeCreate()` | `internal/swarm/worktree.go` | Create isolated git worktree | +| `swarm_worktree_merge` | `swarm.WorktreeMerge()` | `internal/swarm/worktree.go` | Cherry-pick commits back to main | +| `swarm_worktree_cleanup` | `swarm.WorktreeCleanup()` | `internal/swarm/worktree.go` | Remove worktree (idempotent) | +| `swarm_worktree_list` | `swarm.WorktreeList()` | `internal/swarm/worktree.go` | List active worktrees | + +**Implementation approach**: +- Decomposition tools (`swarm_decompose`, `swarm_plan_prompt`, etc.) are primarily prompt generators — they construct structured text from inputs. No LLM calls; the agent calling the tool provides the LLM. +- Progress tracking uses the `events` table (already exists) to record progress events with `type='swarm_progress'` and a JSON payload. +- Worktree tools delegate to `internal/gitutil/`. +- `swarm_complete` runs verification (typecheck + tests) before allowing completion. The verification commands come from the project's build configuration. +- Review tools (`swarm_review`, `swarm_review_feedback`, `swarm_adversarial_review`, `swarm_evaluation_prompt`, `swarm_broadcast`, `swarm_get_strategy_insights`, `swarm_get_file_insights`, `swarm_get_pattern_insights`) are deferred to a follow-up — they depend on the events/outcomes data being populated first. The 16 core tools above are the priority. + +**Database changes**: +- No new tables needed — progress events use the existing `events` table. +- Worktree state is tracked via the filesystem (git worktree list), not in SQLite. + +**Testing strategy**: +- Decomposition tests verify prompt structure (contains expected sections, file lists, etc.) +- Worktree tests use real git repos in `t.TempDir()` +- Progress tests verify event recording and status aggregation +- `TestWorktreeCreate_UncommittedChanges`: Verify worktree creation succeeds even with dirty working directory (per edge case in spec) +- `TestWorktreeMerge_Conflict`: Verify merge failure returns conflicting file list and does NOT clean up the worktree (per edge case in spec) + +### 2.3 Phase 2 Checkpoint + +**Gate**: All 36 tools (20 from Phase 1 + 16 orchestration) pass tests. Git worktree integration tests pass. + +**Expected test count**: ~85 tests + +--- + +## Phase 3: Memory and Context + +**User Story**: US4 (Memory and Context) +**Requirements**: FR-012 through FR-014 +**Estimated Effort**: 1-2 weeks + +### 3.1 Dewey Proxy Client + +Create an HTTP client that proxies memory operations to Dewey's MCP endpoint. + +| Component | Description | +|-----------|-------------| +| `memory.Client` | HTTP client struct with Dewey URL from config | +| `memory.Client.Call(method string, params any) (json.RawMessage, error)` | Send JSON-RPC request to Dewey | +| `memory.Client.Health() error` | Check if Dewey is reachable | + +**Implementation approach**: +- Use stdlib `net/http` — no external HTTP client library needed. +- Dewey URL comes from `config.DeweyURL` (default: `http://localhost:3333/mcp/`). +- All calls are JSON-RPC 2.0 over HTTP POST. +- When Dewey is unreachable, return a structured error with code `DEWEY_UNAVAILABLE`. + +### 3.2 Memory Tools (8 tools) + +| Tool | Behavior | Notes | +|------|----------|-------| +| `hivemind_store` | Proxy to Dewey `dewey_dewey_store_learning` | Include deprecation warning in response | +| `hivemind_find` | Proxy to Dewey `dewey_dewey_semantic_search` | Include deprecation warning in response | +| `hivemind_get` | Return deprecation message | Point to `dewey_get_page` | +| `hivemind_remove` | Return deprecation message | Point to `dewey_delete_page` | +| `hivemind_validate` | Return deprecation message | No direct Dewey equivalent | +| `hivemind_stats` | Return deprecation message | Point to `dewey_health` | +| `hivemind_index` | Return deprecation message | Point to `dewey_reload` | +| `hivemind_sync` | Return deprecation message | Point to `dewey_reload` | + +**Implementation approach**: +- `hivemind_store` and `hivemind_find` are live proxies — they forward the request to Dewey and return the response with an added deprecation notice. +- The remaining 6 tools return a structured deprecation message: `{"deprecated": true, "message": "...", "replacement": "dewey_xxx"}`. +- All tools handle Dewey unavailability gracefully per FR-014. + +**Testing strategy**: +- Use `httptest.NewServer` to mock Dewey responses. +- `TestStore_ProxiesToDewey`: Verify the correct JSON-RPC method and params are forwarded. +- `TestStore_DeweyUnavailable`: Verify structured error when Dewey is down. +- `TestDeprecated_ReturnsMessage`: Verify each deprecated tool returns the correct replacement name. + +### 3.3 Phase 3 Checkpoint + +**Gate**: All 44 tools (36 + 8 memory) pass tests. Dewey proxy handles both available and unavailable states. + +**Expected test count**: ~100 tests + +--- + +## Phase 4: CLI Commands + +**User Story**: US5 (CLI Operations) +**Requirements**: FR-015 through FR-017 +**Estimated Effort**: 1-2 weeks + +### 4.1 CLI Commands + +| Command | Description | Domain Package | +|---------|-------------|----------------| +| `replicator doctor` | Check dependencies and health | `internal/doctor/` | +| `replicator stats` | Display activity metrics | queries `events` table | +| `replicator query ` | Run SQL analytics queries | preset SQL in `internal/query/` | +| `replicator setup` | Initialize environment | create config dir, verify git, etc. | + +**Existing commands** (no changes needed): +- `replicator serve` — MCP server (exists) +- `replicator cells` — List cells (exists) +- `replicator version` — Print version (exists) + +### 4.2 Doctor Command + +The `doctor` command checks for required dependencies and reports their status. + +| Check | Description | Pass Criteria | +|-------|-------------|---------------| +| Git | `git --version` | Exit code 0, version ≥ 2.20 | +| Database | Open and ping SQLite | No error | +| Dewey | HTTP GET to health endpoint | 200 OK | +| Config dir | `~/.config/swarm-tools/` exists | Directory exists | + +**Implementation approach** (per AP-002, AP-003): +- `doctor.Run(opts Options) (*Result, error)` — core logic, testable +- `Options` includes `io.Writer` for output, `Config` for paths +- Each check returns a `CheckResult{Name, Status, Message, Duration}` +- Output formatted as a table to stdout +- Must complete within 2 seconds (SC-008) + +**Testing strategy**: +- Use `Options` struct with `bytes.Buffer` as writer per AP-003 +- Mock external checks (git, Dewey) via interface injection +- `TestDoctor_AllPass`: All checks succeed, output contains ✓ for each +- `TestDoctor_DeweyDown`: Dewey check fails, output shows ✗ with message + +### 4.3 Stats and Query Commands + +- `stats`: Aggregate events table — count by type, recent activity, active agents. Pure SQL queries. +- `query`: Predefined SQL analytics queries (e.g., "agent activity last 24h", "cells by status", "swarm completion rate"). Queries are embedded as Go constants. + +### 4.4 Phase 4 Checkpoint + +**Gate**: All CLI commands execute correctly. `replicator doctor` completes in <2s. Binary starts in <50ms. + +**Verification**: +```bash +time ./bin/replicator version # Verify <50ms startup +./bin/replicator doctor # Verify all checks run +``` + +**Expected test count**: ~115 tests + +--- + +## Phase 5: Parity Testing + +**User Story**: US6 (Parity Verification) +**Requirements**: FR-018, FR-019 +**Estimated Effort**: 2-3 weeks + +### 5.1 Parity Test Harness + +Build a test harness that compares MCP tool response shapes between the Go replicator and the TypeScript cyborg-swarm. + +**Approach**: +1. **Fixture capture**: Run each tool against the TypeScript server, capture response JSON to `test/parity/fixtures/`. +2. **Shape comparison**: For each tool, send the same arguments to the Go server and compare the response shape (field names, types, nesting) — not exact values. +3. **Report generation**: Output a table of all tools with pass/fail status and any shape differences. + +**Implementation**: +- `test/parity/parity_test.go` — Go test file that iterates over fixtures +- `ShapeMatch(expected, actual json.RawMessage) (bool, []Difference)` — recursive JSON shape comparator +- Differences report: field name, expected type, actual type, path (e.g., `$.content[0].text`) + +**Testing strategy**: +- Each tool gets a fixture file: `test/parity/fixtures/hive_create.json` containing `{request, typescript_response}` +- The test sends the same request to the Go server and compares shapes +- Tests are tagged with `//go:build parity` so they don't run in normal CI (they require the TypeScript server) + +### 5.2 Phase 5 Checkpoint + +**Gate**: Parity report shows 100% shape match for all implemented tools (SC-003). + +**Expected final test count**: ~130 tests (115 unit/integration + ~15 parity) + +--- + +## Cross-Cutting Concerns + +### Database Migration Strategy + +All new tables use `CREATE TABLE IF NOT EXISTS` for idempotency, matching the existing pattern in `internal/db/migrations.go`. New migrations are added to the `migrations` slice in order. No versioned migration system is needed — the IF NOT EXISTS pattern handles re-runs. When the Go binary opens an existing cyborg-swarm database, new tables (`sessions`, `messages`, `reservations`) are created automatically via IF NOT EXISTS migrations. Existing data is preserved. + +### Error Handling Pattern + +Follow the established pattern from `internal/hive/cells.go`: +```go +result, err := store.DB.Exec(query, args...) +if err != nil { + return fmt.Errorf("operation context: %w", err) +} +``` + +All errors are wrapped with context per CS-006. Tool execute functions return errors that the MCP server wraps in a content block. + +### Tool Registration Pattern + +Follow the established pattern from `internal/tools/hive/tools.go`: +1. Domain logic in `internal/{domain}/` — pure functions that accept `*db.Store` +2. Tool registration in `internal/tools/{domain}/tools.go` — thin wrappers that unmarshal JSON args, call domain functions, marshal results +3. Registration function: `Register(reg *registry.Registry, store *db.Store)` +4. Called from `cmd/replicator/serve.go` + +### Testing Pattern + +Follow the established pattern from `internal/hive/cells_test.go`: +```go +func testStore(t *testing.T) *db.Store { + t.Helper() + store, err := db.OpenMemory() + if err != nil { + t.Fatalf("OpenMemory: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} +``` + +- In-memory SQLite for unit tests +- `t.TempDir()` for filesystem tests +- `t.Helper()` on all test helpers +- `t.Cleanup()` for teardown +- No testify — stdlib `testing` only (TC-001) + +## Complexity Tracking + +No constitution violations identified. The project structure follows standard Go conventions with a single binary, no external services (except optional Dewey), and a single SQLite database. + +| Concern | Decision | Rationale | +|---------|----------|-----------| +| Git operations | Shell out to `git` binary | Spec assumption. Pure-Go git libs (go-git) add ~15MB to binary and don't support worktrees well. | +| Dewey proxy | HTTP client, not embedded | Dewey is a separate service. Embedding would violate single-responsibility. | +| No mcp-go library | Hand-rolled JSON-RPC | Existing server.go is 212 lines and handles all needed methods. Adding a library would increase binary size and coupling for no benefit. See research.md. | diff --git a/specs/001-go-rewrite-phases/quickstart.md b/specs/001-go-rewrite-phases/quickstart.md new file mode 100644 index 0000000..011d008 --- /dev/null +++ b/specs/001-go-rewrite-phases/quickstart.md @@ -0,0 +1,182 @@ +# Quickstart: Replicator + +**Branch**: `001-go-rewrite-phases` | **Date**: 2026-04-04 + +## Prerequisites + +- **Go 1.25+**: `go version` +- **Git 2.20+**: `git --version` (required for worktree operations in Phase 2+) +- **Dewey** (optional): Required for memory tools in Phase 3+. See [Dewey docs](https://github.com/unbound-force/dewey). + +## Build + +```bash +# Build the binary +make build + +# Output: bin/replicator +``` + +## Test + +```bash +# Run all tests +make test + +# Run all tests with race detector +go test ./... -count=1 -race + +# Run tests for a specific package +go test ./internal/hive/... -count=1 -race + +# Run a specific test +go test ./internal/hive/... -run TestCreateCell -count=1 + +# Quick check (vet + test) +make check +``` + +## Run + +### MCP Server (for AI agents) + +```bash +# Start the MCP server on stdio +make serve + +# Or directly: +./bin/replicator serve +``` + +### CLI Commands + +```bash +# List work items +./bin/replicator cells + +# Print version +./bin/replicator version + +# Phase 4 commands (when implemented): +# ./bin/replicator doctor # Check dependencies +# ./bin/replicator stats # Activity metrics +# ./bin/replicator query # SQL analytics +# ./bin/replicator setup # Initialize environment +``` + +## Development Workflow + +### Adding a New MCP Tool + +1. **Domain logic**: Add function to `internal/{domain}/{file}.go` +2. **Tests**: Add tests to `internal/{domain}/{file}_test.go` +3. **Tool registration**: Add tool to `internal/tools/{domain}/tools.go` +4. **Wire up**: Call `{domain}.Register(reg, store)` in `cmd/replicator/serve.go` +5. **Verify**: `make check` + +### Example: Adding a new hive tool + +```go +// 1. internal/hive/cells.go +func StartCell(store *db.Store, id string) error { + // ... +} + +// 2. internal/hive/cells_test.go +func TestStartCell(t *testing.T) { + store := testStore(t) + // ... +} + +// 3. internal/tools/hive/tools.go +func hiveStart(store *db.Store) *registry.Tool { + return ®istry.Tool{ + Name: "hive_start", + // ... + } +} + +// 4. internal/tools/hive/tools.go Register() +func Register(reg *registry.Registry, store *db.Store) { + // ... existing tools ... + reg.Register(hiveStart(store)) +} +``` + +### Adding a New CLI Command + +```go +// 1. cmd/replicator/doctor.go +func runDoctor(cfg *config.Config, w io.Writer) error { + // Domain logic via internal/doctor/ +} + +// 2. cmd/replicator/main.go +root.AddCommand(doctorCmd()) +``` + +### Database Migrations + +Add new `CREATE TABLE IF NOT EXISTS` statements to `internal/db/migrations.go`: + +```go +const migrationMessages = ` +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- ... +); +` + +// Add to the migrations slice in db.go: +var migrations = []string{ + migrationEvents, + migrationAgents, + migrationCells, + migrationCellEvents, + migrationMessages, // new +} +``` + +## Project Layout + +``` +cmd/replicator/ CLI entrypoint (cobra commands) +internal/ + config/ Configuration (env vars, paths) + db/ SQLite connection + migrations + hive/ Cell domain logic (work items) + mcp/ MCP JSON-RPC server (stdio) + tools/ + registry/ Tool registration system + hive/ Hive tool handlers +specs/ Feature specifications +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `REPLICATOR_DB` | `~/.config/swarm-tools/swarm.db` | SQLite database path | +| `DEWEY_MCP_URL` | `http://localhost:3333/mcp/` | Dewey MCP endpoint | +| `ZEN_API_KEY` | (none) | OpenCode Zen API key | + +## CI + +The CI pipeline runs: + +```bash +go vet ./... +go test ./... -count=1 -race +go build -o bin/replicator ./cmd/replicator +``` + +## Release + +Releases are built via [GoReleaser](https://goreleaser.com/): + +```bash +# Local build for all platforms +goreleaser build --snapshot --clean + +# Targets: linux/darwin (amd64, arm64), windows (amd64) +``` diff --git a/specs/001-go-rewrite-phases/research.md b/specs/001-go-rewrite-phases/research.md new file mode 100644 index 0000000..0b0f6ec --- /dev/null +++ b/specs/001-go-rewrite-phases/research.md @@ -0,0 +1,225 @@ +# Research: Go Rewrite — Library Decisions + +**Branch**: `001-go-rewrite-phases` | **Date**: 2026-04-04 +**Purpose**: Document technical decisions for Go library selection and architectural approach. + +## Decision 1: MCP Server — Hand-Rolled vs mcp-go + +### Context + +The MCP (Model Context Protocol) server handles JSON-RPC 2.0 over stdio. Phase 0 implemented a hand-rolled server in `internal/mcp/server.go` (212 lines). The question is whether to adopt an MCP library for the remaining phases. + +### Options Evaluated + +| Option | Pros | Cons | +|--------|------|------| +| **Keep hand-rolled** (chosen) | Already working, 212 lines, zero dependencies, full control over response shapes, tested | Must handle any new MCP methods manually | +| `github.com/mark3labs/mcp-go` | Community standard, handles protocol details | Adds dependency, may constrain response shapes, version churn risk | +| `github.com/metoro-io/mcp-golang` | Alternative implementation | Less mature, similar trade-offs | + +### Decision + +**Keep the hand-rolled MCP server.** The existing implementation handles `initialize`, `tools/list`, and `tools/call` — the only three methods needed. It's 212 lines with full test coverage. Adding a library would increase binary size and introduce a dependency that could constrain response shape compatibility with cyborg-swarm. + +### Validation + +The hand-rolled server already passes integration tests with real MCP clients (OpenCode, Claude). No protocol compliance issues have been reported. + +--- + +## Decision 2: SQLite Driver — modernc.org/sqlite + +### Context + +Phase 0 chose `modernc.org/sqlite` (pure Go, no CGo). This decision is confirmed and not revisited. + +### Rationale + +- **Zero CGo**: Single binary distribution without C compiler requirements +- **Schema compatibility**: Works with the same SQLite database as cyborg-swarm's `better-sqlite3` +- **WAL mode**: Supports WAL journal mode for concurrent reads +- **Performance**: Adequate for the workload (single-digit millisecond queries on small datasets) + +### Confirmed Settings + +```go +dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=ON", path) +sqlDB.SetMaxOpenConns(1) // Single writer for SQLite +``` + +--- + +## Decision 3: Git Operations — os/exec vs go-git + +### Context + +Swarm orchestration requires git worktree management: create, list, merge (cherry-pick), and cleanup. Two approaches are available. + +### Options Evaluated + +| Option | Pros | Cons | +|--------|------|------| +| **os/exec to git binary** (chosen) | Full git feature support, worktrees work correctly, small binary impact, matches spec assumption | Requires git installed, subprocess overhead | +| `github.com/go-git/go-git` | Pure Go, no external dependency | No worktree support, adds ~15MB to binary, incomplete git feature coverage | + +### Decision + +**Shell out to the `git` binary via `os/exec`.** This is explicitly stated in the spec's Assumptions section: "Git operations (worktree create/merge/cleanup) shell out to the `git` binary rather than using a pure-Go git library." The `doctor` command will verify git is installed. + +### Implementation Pattern + +```go +// internal/gitutil/git.go +func Run(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git %s: %w\nstderr: %s", args[0], err, stderr.String()) + } + return strings.TrimSpace(stdout.String()), nil +} +``` + +--- + +## Decision 4: HTTP Client for Dewey Proxy — stdlib vs third-party + +### Context + +Memory tools proxy requests to Dewey's MCP endpoint over HTTP. Need an HTTP client. + +### Decision + +**Use stdlib `net/http`.** The proxy makes simple JSON-RPC POST requests. No need for retry logic, connection pooling, or advanced features that would justify a third-party client. The Dewey endpoint is localhost. + +### Implementation Pattern + +```go +// internal/memory/proxy.go +type Client struct { + url string + http *http.Client +} + +func NewClient(deweyURL string) *Client { + return &Client{ + url: deweyURL, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (c *Client) Call(method string, params any) (json.RawMessage, error) { + // JSON-RPC 2.0 POST to c.url +} +``` + +--- + +## Decision 5: CLI Framework — cobra (confirmed) + +### Context + +Phase 0 chose `github.com/spf13/cobra` for CLI routing. This is confirmed per CS-009 (`[MUST]`). + +### Phase 4 Commands + +New commands follow the existing pattern from `cmd/replicator/main.go`: + +```go +root.AddCommand(doctorCmd()) +root.AddCommand(statsCmd()) +root.AddCommand(queryCmd()) +root.AddCommand(setupCmd()) +``` + +Each command delegates to a `Run(opts)` function in the corresponding `internal/` package per AP-002. + +--- + +## Decision 6: Logging — charmbracelet/log + +### Context + +CS-008 requires `github.com/charmbracelet/log` for all application logging. Phase 0 has no logging — it's a stdio server that communicates via JSON-RPC. + +### Decision + +**Add `charmbracelet/log` as a dependency when logging is first needed** (likely Phase 2 for worktree operations or Phase 4 for CLI output). Log to stderr only — stdout is reserved for MCP JSON-RPC communication. + +### Deferred Until + +Phase 2 or Phase 4, whichever introduces the first non-MCP output path. Note: The MCP server communicates via stdio JSON-RPC and currently has no application logging. If logging is added, it MUST go to stderr only (stdout is reserved for MCP). The dependency should be added to `go.mod` in the same task that introduces the first `log.Info()` call. + +--- + +## Decision 7: Parity Testing Approach + +### Context + +Phase 5 requires comparing MCP tool response shapes between Go and TypeScript implementations. + +### Options Evaluated + +| Option | Pros | Cons | +|--------|------|------| +| **Fixture-based comparison** (chosen) | Deterministic, no TypeScript server needed at test time, fast | Fixtures can go stale | +| Live dual-server comparison | Always current | Requires TypeScript server running, flaky, slow | +| Schema-based validation | Formal, precise | Requires maintaining JSON schemas for all 70 tools | + +### Decision + +**Fixture-based comparison.** Capture TypeScript responses once, store as JSON fixtures, compare Go responses against the fixture shapes at test time. Fixtures are refreshed manually when the TypeScript version changes. + +### Shape Comparison Algorithm + +``` +ShapeMatch(expected, actual): + if types differ → FAIL (report path, expected type, actual type) + if both objects → recurse on each key + if both arrays → recurse on first element (shape of array items) + if both primitives → PASS (values may differ, shapes match) +``` + +--- + +## Decision 8: Test Isolation for Database Tests + +### Context + +All domain packages need database access for testing. The established pattern uses `db.OpenMemory()`. + +### Decision + +**Continue using in-memory SQLite for all unit tests.** Each test function gets its own `*db.Store` via the `testStore(t)` helper. No shared state between tests (TC-010). The `t.Cleanup()` function closes the store after each test. + +For integration tests that need git (Phase 2), use `t.TempDir()` to create isolated git repositories. + +--- + +## Dependency Summary + +### Current Dependencies (Phase 0) + +| Package | Purpose | Version | +|---------|---------|---------| +| `github.com/spf13/cobra` | CLI framework | v1.10.2 | +| `modernc.org/sqlite` | Pure Go SQLite | v1.48.1 | + +### Planned Additions + +| Package | Phase | Purpose | +|---------|-------|---------| +| `github.com/charmbracelet/log` | 2 or 4 | Structured logging to stderr | + +### Explicitly NOT Adding + +| Package | Reason | +|---------|--------| +| `mcp-go` | Hand-rolled server is simpler and sufficient | +| `go-git` | No worktree support, large binary impact | +| `testify` | Convention pack TC-001 prohibits external test libraries | +| `gorm` / `sqlx` | `database/sql` is sufficient for the query patterns | +| `gin` / `echo` | No HTTP server needed (MCP is stdio, Dewey proxy is client-only) | diff --git a/specs/001-go-rewrite-phases/spec.md b/specs/001-go-rewrite-phases/spec.md new file mode 100644 index 0000000..17c5af0 --- /dev/null +++ b/specs/001-go-rewrite-phases/spec.md @@ -0,0 +1,204 @@ +# Feature Specification: Go Rewrite -- Remaining Phases + +**Feature Branch**: `001-go-rewrite-phases` +**Created**: 2026-04-04 +**Status**: Ready +**Input**: User description: "all the other phases to create a GoLang replacement for swarm-tools" +**References**: [Replicator Phase 0](https://github.com/unbound-force/replicator), [cyborg-swarm](https://github.com/unbound-force/cyborg-swarm) + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Complete Hive Tool Suite (Priority: P1) + +An AI coding agent connects to the replicator MCP server and uses the full hive tool suite to manage work items: create cells, create epics with subtasks, query by status/type, update status, start work, find the next ready cell, and sync to git. The agent's workflow is identical to using the TypeScript version -- same tool names, same argument schemas, same response shapes. + +**Why this priority**: Hive is the foundation for all work tracking. Phase 0 delivered 4 of 11 hive tools. The remaining 7 tools (`hive_create_epic`, `hive_query`, `hive_start`, `hive_ready`, `hive_sync`, `hive_session_start`, `hive_session_end`) complete the work management layer that every other feature depends on. + +**Independent Test**: Create an epic with 3 subtasks via MCP, query for ready cells, start one, complete it, then sync. All operations succeed with correct response shapes matching the TypeScript version. + +**Acceptance Scenarios**: + +1. **Given** the MCP server is running, **When** an agent calls `hive_create_epic` with a title and subtask array, **Then** the epic and all subtasks are created with correct parent-child relationships. +2. **Given** cells exist with dependencies, **When** an agent calls `hive_ready`, **Then** only unblocked cells are returned, sorted by priority. +3. **Given** a cell is open, **When** an agent calls `hive_start`, **Then** the cell status changes to `in_progress` and the timestamp is recorded. +4. **Given** cells have been modified, **When** `hive_sync` is called, **Then** the hive state is serialized and committed to the project's git repository. +5. **Given** an agent begins a session, **When** `hive_session_start` is called, **Then** previous session handoff notes are returned if available. + +--- + +### User Story 2 - Agent Messaging (Priority: P1) + +An AI coding agent initializes a swarm mail session, sends messages to other agents, checks its inbox, reserves files for exclusive editing, and releases reservations. Multiple agents working on the same project can coordinate without conflicts. + +**Why this priority**: Swarm mail is the communication backbone for multi-agent coordination. Without messaging and file reservations, parallel workers cannot coordinate safely. + +**Independent Test**: Initialize two agent sessions, send a message from agent A to agent B, verify it appears in B's inbox, reserve a file path, verify the reservation is enforced, then release it. + +**Acceptance Scenarios**: + +1. **Given** a project path, **When** `swarmmail_init` is called with an agent name, **Then** the agent is registered and a session is created. +2. **Given** two registered agents, **When** agent A calls `swarmmail_send` with a message to agent B, **Then** the message is persisted and appears in B's `swarmmail_inbox`. +3. **Given** agent A has sent a message, **When** agent B calls `swarmmail_read_message` with the message ID, **Then** the full message body is returned. +4. **Given** a file path, **When** agent A calls `swarmmail_reserve` with exclusive mode, **Then** the file is locked and agent B's attempt to reserve the same file fails with a conflict error. +5. **Given** agent A holds a reservation, **When** agent A calls `swarmmail_release`, **Then** the file is unlocked and available for other agents. +6. **Given** a message requires acknowledgment, **When** agent B calls `swarmmail_ack`, **Then** the message is marked as acknowledged. + +--- + +### User Story 3 - Swarm Orchestration (Priority: P1) + +An AI coding agent decomposes a task into subtasks, spawns parallel workers in isolated git worktrees, tracks progress, merges results, and reviews completed work. The full swarm coordination lifecycle works end-to-end. + +**Why this priority**: Orchestration is the core differentiator of the tool. Task decomposition, parallel execution, and worktree isolation are the primary reasons agents use this system. + +**Independent Test**: Decompose a task description into subtasks, create worktrees for 2 parallel workers, report progress, complete the subtasks, merge worktrees back, and verify the merged result is clean. + +**Acceptance Scenarios**: + +1. **Given** a task description, **When** `swarm_decompose` is called, **Then** a structured decomposition prompt is returned with file assignments. +2. **Given** a project path and commit hash, **When** `swarm_worktree_create` is called, **Then** an isolated git worktree is created at the expected path. +3. **Given** an active worktree, **When** `swarm_spawn_subtask` is called, **Then** the subtask prompt includes the correct context, files, and worktree path. +4. **Given** a worker is executing, **When** `swarm_progress` is called with a percentage, **Then** the progress is recorded and visible via `swarm_status`. +5. **Given** a completed worktree, **When** `swarm_worktree_merge` is called, **Then** the worker's commits are cherry-picked onto the main branch. +6. **Given** all subtasks are complete, **When** `swarm_status` is called, **Then** it shows all subtasks as completed with their outcomes. + +--- + +### User Story 4 - Memory and Context (Priority: P2) + +An AI coding agent stores learnings, searches for relevant context, and retrieves past decisions through the memory tools. Memory operations are proxied through Dewey's semantic search service for cross-repo context. + +**Why this priority**: Memory enables agents to learn from past sessions and avoid repeating mistakes. It's important but not blocking for the core coordination workflow. + +**Independent Test**: Store a learning with tags, search for it by semantic query, retrieve it by ID, and validate the response matches the stored content. + +**Acceptance Scenarios**: + +1. **Given** Dewey is available, **When** `hivemind_store` is called with information and tags, **Then** the memory is persisted through Dewey and a deprecation warning is included in the response. +2. **Given** memories exist, **When** `hivemind_find` is called with a query, **Then** semantically relevant results are returned in the expected response format. +3. **Given** Dewey is unavailable, **When** any memory tool is called, **Then** a clear error message is returned explaining the backend is unreachable. +4. **Given** a secondary memory tool is called (`hivemind_get`, `hivemind_stats`, etc.), **Then** a deprecation message is returned with the Dewey replacement tool name. + +--- + +### User Story 5 - CLI Operations (Priority: P2) + +A developer uses the `replicator` CLI to set up their environment, check health, query swarm activity, and inspect work items. The CLI provides the same observability as the TypeScript `swarm` CLI but starts instantly. + +**Why this priority**: CLI commands are the developer-facing interface. They're important for observability but don't block agent operations (which use MCP tools). + +**Independent Test**: Run `replicator setup`, `replicator doctor`, `replicator cells`, `replicator stats`, and `replicator query` and verify correct output for each. + +**Acceptance Scenarios**: + +1. **Given** a fresh environment, **When** `replicator doctor` is run, **Then** it checks for required dependencies and reports their status. +2. **Given** an active swarm with completed work, **When** `replicator stats` is run, **Then** it displays health metrics and activity summary. +3. **Given** a database with events, **When** `replicator query` is run with a preset name, **Then** it executes the SQL analytics query and displays results. +4. **Given** a project with cells, **When** `replicator cells` is run with a status filter, **Then** matching cells are displayed as formatted output. + +--- + +### User Story 6 - Parity Verification (Priority: P3) + +A maintainer runs a parity test suite that compares the replicator's MCP tool responses against the TypeScript cyborg-swarm's responses for the same inputs. All tools produce identical response shapes and semantically equivalent results. + +**Why this priority**: Parity verification is the final quality gate before the replicator can replace cyborg-swarm. It depends on all other stories being complete. + +**Independent Test**: For each of the 70 unique MCP tools, send identical arguments to both the Go and TypeScript servers and compare response shapes. Zero shape mismatches. + +**Acceptance Scenarios**: + +1. **Given** both servers are running, **When** identical `hive_create` arguments are sent to each, **Then** both return the same JSON shape with matching field names and types. +2. **Given** the parity suite completes, **When** results are inspected, **Then** all 70 unique tools show "shape match" status. +3. **Given** any response shape mismatch, **When** the parity report is generated, **Then** it identifies the specific field, expected type, and actual type. + +--- + +### Edge Cases + +- What happens when the SQLite database file is locked by another process? The system should retry with exponential backoff and report a clear error if the lock cannot be acquired within a reasonable timeout. +- What happens when `swarm_worktree_create` is called but the git working directory has uncommitted changes? The worktree is created from the specified commit hash, not the working directory state, so uncommitted changes do not affect worktree creation. +- What happens when `swarm_worktree_merge` encounters a conflict? The merge fails with a clear error listing the conflicting files, and the worktree is NOT cleaned up (allowing manual resolution). +- What happens when Dewey is down during `hivemind_store`? The tool returns a structured error with code `DEWEY_UNAVAILABLE` and a hint to check the Dewey service. +- What happens when a CLI command is run but no database exists? The database is auto-created with the full schema on first access. +- How does the system handle the TypeScript cyborg-swarm's database during migration? The schema is wire-compatible -- the Go binary reads and writes the same SQLite database file at `~/.config/swarm-tools/swarm.db`. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Hive (Phase 1) +- **FR-001**: All 11 hive MCP tools MUST be implemented with argument schemas and response shapes matching the TypeScript version. +- **FR-002**: The `hive_create_epic` tool MUST support atomic creation of an epic with N subtasks in a single call. +- **FR-003**: The `hive_ready` tool MUST return only unblocked cells (no open dependencies), sorted by priority. +- **FR-004**: The `hive_sync` tool MUST serialize cell state and commit to the project's `.hive/` directory. + +#### Swarm Mail (Phase 1) +- **FR-005**: All 9 swarm mail MCP tools MUST be implemented with matching schemas and response shapes. +- **FR-006**: File reservations MUST enforce exclusive locking -- concurrent reserve attempts on the same path MUST fail with a conflict error. +- **FR-007**: Messages MUST persist across process restarts (stored in SQLite). + +#### Orchestration (Phase 2) +- **FR-008**: All 16 swarm orchestration MCP tools MUST be implemented. +- **FR-009**: The `swarm_worktree_create` tool MUST create isolated git worktrees using `git worktree add`. +- **FR-010**: The `swarm_worktree_merge` tool MUST cherry-pick worker commits back to the main branch and detect conflicts. +- **FR-011**: The `swarm_decompose` tool MUST generate decomposition prompts that match the TypeScript version's structure. + +#### Memory (Phase 3) +- **FR-012**: The `hivemind_store` and `hivemind_find` tools MUST proxy through Dewey's MCP endpoint. +- **FR-013**: The 6 secondary hivemind tools MUST return structured deprecation messages with replacement tool names. +- **FR-014**: When Dewey is unavailable, proxied tools MUST return a clear error rather than silently failing. + +#### CLI (Phase 4) +- **FR-015**: The binary MUST start in under 50 milliseconds (cold start, no warm cache). +- **FR-016**: CLI commands MUST include at minimum: `serve`, `cells`, `doctor`, `stats`, `query`, `version`. +- **FR-017**: The `doctor` command MUST check for Dewey availability, database accessibility, and git configuration. + +#### Parity (Phase 5) +- **FR-018**: A parity test suite MUST compare response shapes for all implemented tools against the TypeScript version. +- **FR-019**: The parity report MUST list every tool with pass/fail status and any shape differences. + +#### Cross-Cutting +- **FR-020**: The database schema MUST be compatible with cyborg-swarm's SQLite database (same table names, column names, and types). +- **FR-021**: The MCP server MUST handle the `initialize`, `tools/list`, and `tools/call` methods per the MCP specification. +- **FR-022**: All database operations MUST use WAL mode for concurrent read access. +- **FR-023**: The binary MUST be distributable as a single file with zero runtime dependencies. + +### Key Entities + +- **Cell**: A work item with ID, title, description, type (task/bug/feature/epic/chore), status (open/in_progress/blocked/closed), priority, and optional parent-child relationships. +- **Agent**: A registered AI agent with name, project path, role, status, and metadata. Agents interact via swarm mail. +- **Message**: An inter-agent communication with sender, recipients, subject, body, importance level, and thread ID. +- **Reservation**: A file lock held by an agent for exclusive editing, with path, TTL, and exclusivity flag. +- **Worktree**: An isolated git working directory created for a specific subtask, with task ID, branch name, and status. +- **Memory/Learning**: A piece of knowledge stored via Dewey, with information text, tags, and semantic embedding. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All 70 unique MCP tools are implemented and pass their individual test suites with zero failures. +- **SC-002**: The binary starts and responds to the first MCP request in under 50 milliseconds (measured from process launch to first JSON-RPC response). +- **SC-003**: The parity test suite shows 100% response shape match for all tools compared to the TypeScript version. +- **SC-004**: The binary size is under 20MB for all target platforms (macOS/Linux arm64 and amd64). +- **SC-005**: The test suite achieves at least 80% line coverage across all internal packages. +- **SC-006**: Two parallel agents can coordinate via swarm mail (send messages, reserve files, report progress) without data corruption or deadlocks. +- **SC-007**: The full suite of 70 tools completes implementation within 8 months of Phase 0 completion. +- **SC-008**: The CLI `doctor` command detects and reports all required dependencies within 2 seconds. + +## Assumptions + +- Phase 0 (scaffold) is complete: MCP server, SQLite, tool registry, and 4 hive tools are working. +- The database schema at `~/.config/swarm-tools/swarm.db` is the shared state between the Go and TypeScript versions during migration. +- Dewey is the canonical memory backend; Ollama embedding operations are handled by Dewey, not by replicator. +- The eval system (`swarm-evals`) remains in TypeScript and is NOT part of this rewrite. +- The dashboard web UI (`swarm-dashboard`) remains in TypeScript and is NOT part of this rewrite. +- Git operations (worktree create/merge/cleanup) shell out to the `git` binary rather than using a pure-Go git library. +- The `swarm-queue` package (BullMQ/Redis) is deferred -- it can be added later if Redis-based queuing is needed. + +## Dependencies + +- **Phase 0 (complete)**: MCP server, SQLite, tool registry, 4 hive tools. +- **Dewey**: Must be running for memory proxy tools to function. Graceful degradation when unavailable. +- **Git**: Must be installed for worktree operations. The `doctor` command checks for this. +- **cyborg-swarm**: The TypeScript version serves as the behavioral reference for parity testing. diff --git a/specs/001-go-rewrite-phases/tasks.md b/specs/001-go-rewrite-phases/tasks.md new file mode 100644 index 0000000..f1039f2 --- /dev/null +++ b/specs/001-go-rewrite-phases/tasks.md @@ -0,0 +1,254 @@ +# Tasks: Go Rewrite — Remaining Phases + +**Input**: Design documents from `/specs/001-go-rewrite-phases/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, quickstart.md +**Branch**: `001-go-rewrite-phases` + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[US1–US6]**: Which user story this task belongs to +- Exact file paths included in every task description +- TDD: test tasks appear alongside or before implementation tasks +- **TDD ordering**: Within each `[P]` group, follow Red-Green-Refactor — write the failing test first, then the implementation. Test tasks (e.g., T006) and implementation tasks (e.g., T005) in the same `[P]` group are executed by the same worker in TDD order. + +--- + +## Phase 1: Hive Completion + Swarm Mail + +**User Stories**: US1 (Complete Hive Tool Suite), US2 (Agent Messaging) +**Requirements**: FR-001 through FR-007, FR-020, FR-022 +**Gate**: `make check` passes — all 20 tools (4 existing + 7 hive + 9 swarmmail) have passing tests + +### 1A — Database Migrations (shared infrastructure for Phase 1) + +- [x] T001 [US1] Add `sessions` table migration in `internal/db/migrations.go` — columns: session_id TEXT PK, agent_name TEXT, project_path TEXT, started_at TEXT, ended_at TEXT, handoff_notes TEXT, active_cell_id TEXT. Register in `migrations` slice in `internal/db/db.go`. +- [x] T002 [US2] Add `messages` table migration in `internal/db/migrations.go` — columns: id INTEGER PK AUTOINCREMENT, from_agent TEXT, to_agents TEXT (JSON), subject TEXT, body TEXT, importance TEXT, thread_id TEXT, ack_required INTEGER, acknowledged INTEGER, created_at TEXT. Add index on from_agent, created_at. +- [x] T003 [US2] Add `reservations` table migration in `internal/db/migrations.go` — columns: id INTEGER PK AUTOINCREMENT, agent_name TEXT, project_path TEXT, path TEXT, exclusive INTEGER, reason TEXT, ttl_seconds INTEGER, created_at TEXT, expires_at TEXT. Add index on path, agent_name. +- [x] T004 Test migration idempotency: add test in `internal/db/db_test.go` verifying all new tables are created by `db.OpenMemory()` and that calling `migrate()` twice is safe. + +### 1B — Remaining Hive Domain Logic (US1) + +- [x] T005 [P] [US1] Implement `CreateEpic` in `internal/hive/epic.go` — accept `CreateEpicInput{EpicTitle, EpicDescription, Subtasks []SubtaskInput}`, use `sql.Tx` for atomic insert of epic + N subtask rows with `parent_id`. Add `CreateEpicInput` and `SubtaskInput` types. +- [x] T006 [P] [US1] Add tests in `internal/hive/epic_test.go` — `TestCreateEpic_Success` (epic + 3 subtasks, verify parent_id), `TestCreateEpic_AtomicRollback` (simulate failure, verify no partial rows), `TestCreateEpic_EmptySubtasks` (epic with zero subtasks succeeds). +- [x] T007 [P] [US1] Implement `StartCell` in `internal/hive/cells.go` — set `status='in_progress'`, update `updated_at`. Return error if cell not found. +- [x] T008 [P] [US1] Implement `ReadyCell` in `internal/hive/cells.go` — query cells where `status='open'` and no parent cell has `status` in ('open', 'in_progress', 'blocked'). Order by `priority DESC`. Return first match (single cell, not a list). +- [x] T009 [P] [US1] Add tests in `internal/hive/cells_test.go` — `TestStartCell_Success`, `TestStartCell_NotFound`, `TestReadyCell_UnblockedOnly` (create parent+child, verify only unblocked returned), `TestReadyCell_PriorityOrder`, `TestReadyCell_NoReady` (all blocked, returns nil). +- [x] T010 [US1] Implement `Sync` in `internal/hive/sync.go` — serialize all cells to JSON, write to `.hive/cells.json` in project dir, shell out to `git add .hive/ && git commit -m "hive sync"` via `os/exec`. Accept `projectPath string` parameter. +- [x] T011 [US1] Add tests in `internal/hive/sync_test.go` — `TestSync_WritesJSON` (use `t.TempDir()`, verify file contents), `TestSync_CommitsToGit` (init git repo in tempdir, run sync, verify commit exists via `git log`). +- [x] T012 [US1] Implement `SessionStart` and `SessionEnd` in `internal/hive/session.go` — `SessionStart(store, agentName, activeCellID)` creates session row, returns previous session's handoff_notes. `SessionEnd(store, handoffNotes)` updates current session with ended_at and handoff_notes. +- [x] T013 [US1] Add tests in `internal/hive/session_test.go` — `TestSessionStart_ReturnsHandoff` (create session with notes, start new, verify notes returned), `TestSessionStart_NoPrevious` (first session returns empty), `TestSessionEnd_SavesNotes`. + +### 1C — Hive Tool Registration (US1) + +- [x] T014 [US1] Register `hive_create_epic` tool in `internal/tools/hive/tools.go` — unmarshal `CreateEpicInput`, call `hive.CreateEpic()`, marshal result. Schema: `epic_title` (required string), `epic_description` (string), `subtasks` (array of objects with `title`, `priority`, `files`). +- [x] T015 [US1] Register `hive_query` tool in `internal/tools/hive/tools.go` — alias for existing `hive_cells` that matches the TypeScript tool name. Same schema and handler as `hiveCells()`. +- [x] T016 [US1] Register `hive_start` tool in `internal/tools/hive/tools.go` — unmarshal `{id: string}`, call `hive.StartCell()`. Schema: `id` (required string). +- [x] T017 [US1] Register `hive_ready` tool in `internal/tools/hive/tools.go` — no required args, call `hive.ReadyCell()`, marshal result. Return empty result if no ready cell. +- [x] T018 [US1] Register `hive_sync` tool in `internal/tools/hive/tools.go` — unmarshal `{auto_pull: boolean}`, call `hive.Sync()`. Schema: `auto_pull` (optional boolean). +- [x] T019 [US1] Register `hive_session_start` tool in `internal/tools/hive/tools.go` — unmarshal `{active_cell_id: string}`, call `hive.SessionStart()`. Schema: `active_cell_id` (optional string). +- [x] T020 [US1] Register `hive_session_end` tool in `internal/tools/hive/tools.go` — unmarshal `{handoff_notes: string}`, call `hive.SessionEnd()`. Schema: `handoff_notes` (required string). + +### 1D — Swarm Mail Domain Logic (US2) + +- [x] T021 [P] [US2] Implement `Init` in `internal/swarmmail/agent.go` — upsert agent into `agents` table (update `last_seen_at` if exists), accept `agentName`, `projectPath`, `taskDescription`. Return agent record. Add package-level types. +- [x] T022 [P] [US2] Add tests in `internal/swarmmail/agent_test.go` — `TestInit_NewAgent`, `TestInit_ExistingAgent` (verify upsert updates `last_seen_at`), `TestInit_DefaultRole`. +- [x] T023 [P] [US2] Implement `Send`, `Inbox`, `ReadMessage`, `Ack` in `internal/swarmmail/message.go` — `Send(store, msg)` inserts message row. `Inbox(store, agentName, limit, urgentOnly)` returns messages without body (max 5). `ReadMessage(store, messageID)` returns full message. `Ack(store, messageID)` sets `acknowledged=1`. +- [x] T024 [P] [US2] Add tests in `internal/swarmmail/message_test.go` — `TestSend_Persists`, `TestInbox_ExcludesBodies`, `TestInbox_MaxFive`, `TestInbox_UrgentOnly`, `TestReadMessage_FullBody`, `TestAck_MarksAcknowledged`, `TestReadMessage_NotFound`. +- [x] T025 [P] [US2] Implement `Reserve`, `Release`, `ReleaseAll`, `ReleaseAgent` in `internal/swarmmail/reservation.go` — `Reserve(store, agentName, paths, exclusive, reason, ttlSeconds)` checks for existing exclusive reservations (excluding expired) before inserting. `Release(store, paths, reservationIDs)` deletes by path or ID. `ReleaseAll(store, projectPath)` deletes all for project. `ReleaseAgent(store, agentName)` deletes all for agent. +- [x] T026 [P] [US2] Add tests in `internal/swarmmail/reservation_test.go` — `TestReserve_ExclusiveConflict` (agent A reserves, agent B fails), `TestReserve_TTLExpiry` (expired reservation allows re-reserve), `TestReserve_NonExclusive` (multiple non-exclusive succeed), `TestRelease_ByPath`, `TestRelease_ByID`, `TestReleaseAll`, `TestReleaseAgent`. + +### 1E — Swarm Mail Tool Registration (US2) + +- [x] T027 [US2] Create `internal/tools/swarmmail/tools.go` — implement `Register(reg, store)` function. Register all 9 swarmmail tools: `swarmmail_init`, `swarmmail_send`, `swarmmail_inbox`, `swarmmail_read_message`, `swarmmail_reserve`, `swarmmail_release`, `swarmmail_release_all`, `swarmmail_release_agent`, `swarmmail_ack`. Each tool: unmarshal JSON args, call domain function, marshal result. Match TypeScript argument schemas. +- [x] T028 [US2] Add `swarmmail_health` tool in `internal/tools/swarmmail/tools.go` — return database health status (ping SQLite, count agents/messages/reservations). + +### 1F — Wire Up Phase 1 + Checkpoint + +- [x] T029 Wire swarmmail tools into MCP server in `cmd/replicator/serve.go` — import `internal/tools/swarmmail`, call `swarmmail.Register(reg, store)` after `hive.Register()`. +- [x] T030 Phase 1 checkpoint: run `make check` (go vet + go test ./... -count=1 -race). All 20 tools registered, all tests pass. Verify tool count via `registry.Count() == 20`. + +--- + +## Phase 2: Swarm Orchestration + +**User Story**: US3 (Swarm Orchestration) +**Requirements**: FR-008 through FR-011 +**Gate**: `make check` passes — all 36 tools (20 + 16 orchestration) have passing tests + +### 2A — Git Utilities (shared infrastructure for Phase 2) + +- [x] T031 [P] [US3] Create `internal/gitutil/git.go` — implement `Run(dir string, args ...string) (string, error)` using `os/exec.Command("git", args...)`. Set `cmd.Dir`, capture stdout/stderr, return stdout trimmed. On error, include stderr in wrapped error message. +- [x] T032 [P] [US3] Add `WorktreeAdd(projectPath, worktreePath, branch, startCommit)`, `WorktreeRemove(worktreePath)`, `WorktreeList(projectPath) ([]WorktreeInfo, error)` to `internal/gitutil/git.go`. Parse `git worktree list --porcelain` output into `WorktreeInfo{Path, Branch, Commit}` struct. +- [x] T033 [P] [US3] Add `CherryPick(projectPath, worktreeBranch, startCommit)`, `CurrentCommit(projectPath)` to `internal/gitutil/git.go`. `CherryPick` identifies commits on worktree branch since `startCommit` and cherry-picks each onto current branch. On conflict, return error listing conflicting files. +- [x] T034 [P] [US3] Add tests in `internal/gitutil/git_test.go` — guard with `if testing.Short() { t.Skip() }`. Tests: `TestRun_Success`, `TestRun_Failure` (bad command returns stderr), `TestWorktreeAdd_CreatesDirectory`, `TestWorktreeRemove`, `TestWorktreeList_ParsesPorcelain`, `TestCherryPick_AppliesCommits`, `TestCherryPick_DetectsConflict`, `TestCurrentCommit`. All use `t.TempDir()` with `git init`. + +### 2B — Swarm Decomposition (US3) + +- [x] T035 [P] [US3] Implement `Init` in `internal/swarm/init.go` — initialize swarm session, record event in `events` table with `type='swarm_init'`. Accept `projectPath`, `isolation` mode. Return session info. +- [x] T036 [P] [US3] Add tests in `internal/swarm/init_test.go` — `TestInit_RecordsEvent`, `TestInit_ReturnsSession`. +- [x] T037 [P] [US3] Implement `SelectStrategy`, `PlanPrompt`, `Decompose`, `ValidateDecomposition` in `internal/swarm/decompose.go` — these are prompt generators (no LLM calls). `SelectStrategy(task, context)` returns recommended strategy. `PlanPrompt(task, strategy, context, maxSubtasks)` returns structured prompt text. `Decompose(task, context, maxSubtasks)` returns decomposition prompt. `ValidateDecomposition(response)` validates JSON response against CellTree schema. +- [x] T038 [P] [US3] Add tests in `internal/swarm/decompose_test.go` — `TestSelectStrategy_ReturnsStrategy`, `TestPlanPrompt_ContainsSections`, `TestDecompose_IncludesFileList`, `TestValidateDecomposition_ValidJSON`, `TestValidateDecomposition_InvalidJSON`. + +### 2C — Swarm Spawn + Subtask Management (US3) + +- [x] T039 [P] [US3] Implement `SubtaskPrompt`, `SpawnSubtask`, `CompleteSubtask` in `internal/swarm/spawn.go` — `SubtaskPrompt(agentName, beadID, epicID, title, files, sharedContext)` generates prompt text for spawned agent. `SpawnSubtask(beadID, epicID, title, files, description, sharedContext)` prepares subtask metadata. `CompleteSubtask(beadID, taskResult, filesTouched)` handles subtask completion. +- [x] T040 [P] [US3] Add tests in `internal/swarm/spawn_test.go` — `TestSubtaskPrompt_IncludesContext`, `TestSubtaskPrompt_IncludesFiles`, `TestSpawnSubtask_ReturnsMetadata`, `TestCompleteSubtask_RecordsResult`. + +### 2D — Swarm Progress + Status (US3) + +- [x] T041 [P] [US3] Implement `Progress`, `Complete`, `Status`, `RecordOutcome` in `internal/swarm/progress.go` — `Progress(projectKey, agentName, beadID, status, progressPercent, message, filesTouched)` records progress event. `Complete(projectKey, agentName, beadID, summary, filesTouched, evaluation)` marks subtask complete (runs verification if not skipped). `Status(epicID, projectKey)` aggregates subtask statuses. `RecordOutcome(beadID, durationMs, success, strategy, filesTouched, errorCount, retryCount)` records outcome for feedback scoring. +- [x] T042 [P] [US3] Add tests in `internal/swarm/progress_test.go` — `TestProgress_RecordsEvent`, `TestComplete_MarksComplete`, `TestStatus_AggregatesSubtasks`, `TestRecordOutcome_PersistsData`. + +### 2E — Swarm Worktree Operations (US3) + +- [x] T043 [US3] Implement `WorktreeCreate`, `WorktreeMerge`, `WorktreeCleanup`, `WorktreeList` in `internal/swarm/worktree.go` — delegate to `internal/gitutil/`. `WorktreeCreate(projectPath, taskID, startCommit)` creates worktree at `{projectPath}/.worktrees/{taskID}`. `WorktreeMerge(projectPath, taskID, startCommit)` cherry-picks commits back. `WorktreeCleanup(projectPath, taskID, cleanupAll)` removes worktree (idempotent). `WorktreeList(projectPath)` returns active worktrees. +- [x] T044 [US3] Add tests in `internal/swarm/worktree_test.go` — guard with `if testing.Short() { t.Skip() }`. Tests: `TestWorktreeCreate_CreatesIsolatedDir`, `TestWorktreeMerge_CherryPicksCommits`, `TestWorktreeMerge_ConflictPreservesWorktree` (per spec edge case), `TestWorktreeCleanup_Idempotent`, `TestWorktreeList_ReturnsActive`. + +### 2F — Swarm Review + Insights (US3) + +- [x] T045 [P] [US3] Implement `Review`, `ReviewFeedback`, `AdversarialReview`, `EvaluationPrompt`, `Broadcast` in `internal/swarm/review.go` — prompt generators for review workflows. `Review(projectKey, epicID, taskID, filesTouched)` generates review prompt with diff. `ReviewFeedback(projectKey, taskID, workerID, status, issues, summary)` tracks review attempts (max 3). `AdversarialReview(diff, testOutput)` generates adversarial review prompt. `EvaluationPrompt(beadID, title, filesTouched)` generates self-evaluation prompt. `Broadcast(projectPath, agentName, epicID, message, importance, filesAffected)` sends context update. +- [x] T046 [P] [US3] Add tests in `internal/swarm/review_test.go` — `TestReview_IncludesDiff`, `TestReviewFeedback_TracksAttempts`, `TestReviewFeedback_FailsAfterThreeRejections`, `TestAdversarialReview_GeneratesPrompt`, `TestEvaluationPrompt_IncludesFiles`, `TestBroadcast_RecordsEvent`. + +### 2G — Swarm Insights (US3) + +- [x] T047 [P] [US3] Implement `GetStrategyInsights`, `GetFileInsights`, `GetPatternInsights` in `internal/swarm/insights.go` — query `events` table for past outcomes. `GetStrategyInsights(task)` returns success rates by strategy. `GetFileInsights(files)` returns file-specific gotchas. `GetPatternInsights()` returns top 5 failure patterns. +- [x] T048 [P] [US3] Add tests in `internal/swarm/insights_test.go` — `TestGetStrategyInsights_ReturnsRates`, `TestGetFileInsights_ReturnsGotchas`, `TestGetPatternInsights_ReturnsTopFive`, `TestInsights_EmptyDatabase`. + +### 2H — Swarm Tool Registration (US3) + +- [x] T049 [US3] Create `internal/tools/swarm/tools.go` — implement `Register(reg, store)`. Register all swarm tools: `swarm_init`, `swarm_select_strategy`, `swarm_plan_prompt`, `swarm_decompose`, `swarm_validate_decomposition`, `swarm_subtask_prompt`, `swarm_spawn_subtask`, `swarm_complete_subtask`, `swarm_progress`, `swarm_complete`, `swarm_status`, `swarm_record_outcome`, `swarm_worktree_create`, `swarm_worktree_merge`, `swarm_worktree_cleanup`, `swarm_worktree_list`, `swarm_review`, `swarm_review_feedback`, `swarm_adversarial_review`, `swarm_evaluation_prompt`, `swarm_broadcast`, `swarm_get_strategy_insights`, `swarm_get_file_insights`, `swarm_get_pattern_insights`. Each tool: unmarshal JSON args, call domain function, marshal result. Match TypeScript argument schemas. + +### 2I — Wire Up Phase 2 + Checkpoint + +- [x] T050 Wire swarm tools into MCP server in `cmd/replicator/serve.go` — import `internal/tools/swarm`, call `swarm.Register(reg, store)`. +- [x] T051 Phase 2 checkpoint: run `make check`. All 44+ tools registered, all tests pass. Git worktree integration tests pass (skip in `-short` mode). + +--- + +## Phase 3: Memory and Context + +**User Story**: US4 (Memory and Context) +**Requirements**: FR-012 through FR-014 +**Gate**: `make check` passes — all 52+ tools have passing tests, Dewey proxy handles available and unavailable states + +### 3A — Dewey Proxy Client (US4) + +- [x] T052 [P] [US4] Create `internal/memory/proxy.go` — implement `Client` struct with `url string` and `http *http.Client`. Constructor `NewClient(deweyURL string) *Client` with 10s timeout. Method `Call(method string, params any) (json.RawMessage, error)` sends JSON-RPC 2.0 POST. Method `Health() error` pings Dewey endpoint. On connection failure, return structured error with code `DEWEY_UNAVAILABLE`. +- [x] T053 [P] [US4] Add tests in `internal/memory/proxy_test.go` — use `httptest.NewServer` to mock Dewey. Tests: `TestCall_ForwardsRequest`, `TestCall_ReturnsResponse`, `TestCall_DeweyUnavailable` (no server, verify error code), `TestHealth_Success`, `TestHealth_Failure`. + +### 3B — Memory Tools (US4) + +- [x] T054 [P] [US4] Implement `Store` and `Find` in `internal/memory/proxy.go` — `Store(client, information, tags)` proxies to Dewey `dewey_dewey_store_learning`, adds deprecation warning to response. `Find(client, query, collection, limit)` proxies to Dewey `dewey_dewey_semantic_search`, adds deprecation warning. +- [x] T055 [P] [US4] Add tests in `internal/memory/proxy_test.go` — `TestStore_ProxiesToDewey` (verify correct JSON-RPC method/params forwarded), `TestStore_IncludesDeprecationWarning`, `TestFind_ProxiesToDewey`, `TestFind_IncludesDeprecationWarning`. +- [x] T056 [P] [US4] Implement deprecated tool stubs in `internal/memory/deprecated.go` — `DeprecatedResponse(toolName, replacementTool string) string` returns `{"deprecated": true, "message": "...", "replacement": "dewey_xxx"}`. Map: `hivemind_get` → `dewey_get_page`, `hivemind_remove` → `dewey_delete_page`, `hivemind_validate` → (no equivalent), `hivemind_stats` → `dewey_health`, `hivemind_index` → `dewey_reload`, `hivemind_sync` → `dewey_reload`. +- [x] T057 [P] [US4] Add tests in `internal/memory/deprecated_test.go` — `TestDeprecatedResponse_IncludesReplacement` (for each of 6 deprecated tools, verify correct replacement name), `TestDeprecatedResponse_JSONShape`. + +### 3C — Memory Tool Registration (US4) + +- [x] T058 [US4] Create `internal/tools/memory/tools.go` — implement `Register(reg, client)` accepting `*memory.Client`. Register 8 tools: `hivemind_store`, `hivemind_find` (proxy tools), `hivemind_get`, `hivemind_remove`, `hivemind_validate`, `hivemind_stats`, `hivemind_index`, `hivemind_sync` (deprecated stubs). Match TypeScript argument schemas. + +### 3D — Wire Up Phase 3 + Checkpoint + +- [x] T059 Wire memory tools into MCP server in `cmd/replicator/serve.go` — create `memory.NewClient(cfg.DeweyURL)`, import `internal/tools/memory`, call `memory.Register(reg, memClient)`. +- [x] T060 Add `DeweyURL` field to `internal/config/config.go` — default `http://localhost:3333/mcp/`, read from `DEWEY_MCP_URL` env var. +- [x] T061 Phase 3 checkpoint: run `make check`. All 52+ tools registered, all tests pass. Dewey proxy tests use httptest mocks. + +--- + +## Phase 4: CLI Commands + +**User Story**: US5 (CLI Operations) +**Requirements**: FR-015 through FR-017 +**Gate**: `make check` passes — all CLI commands execute correctly, `replicator doctor` completes in <2s, binary starts in <50ms + +### 4A — Doctor Command (US5) + +- [x] T062 [P] [US5] Create `internal/doctor/checks.go` — define `CheckResult{Name, Status, Message, Duration}` and `Options{Writer io.Writer, Config *config.Config, GitChecker, DeweyChecker}` types. Implement `Run(opts Options) ([]CheckResult, error)`. Individual check functions: `checkGit()` (run `git --version`, verify exit 0), `checkDatabase()` (open and ping SQLite), `checkDewey()` (HTTP GET to health endpoint), `checkConfigDir()` (verify `~/.config/swarm-tools/` exists). Use interface injection for external checks. +- [x] T063 [P] [US5] Add tests in `internal/doctor/checks_test.go` — `TestDoctor_AllPass` (mock all checks passing, verify output contains ✓), `TestDoctor_DeweyDown` (mock Dewey failure, verify ✗ with message), `TestDoctor_GitMissing`, `TestDoctor_CompletesInTwoSeconds`. Use `bytes.Buffer` as writer. +- [x] T064 [US5] Create `cmd/replicator/doctor.go` — implement `doctorCmd() *cobra.Command` and `runDoctor()` that creates `doctor.Options` and calls `doctor.Run()`. Format output as table to stdout. + +### 4B — Stats Command (US5) + +- [x] T065 [P] [US5] Implement stats queries in `internal/stats/stats.go` — `Run(store *db.Store, w io.Writer) error`. Query `events` table: count by type, recent activity (last 24h), active agents, cell counts by status. Format as human-readable table. +- [x] T066 [P] [US5] Add tests in `internal/stats/stats_test.go` — `TestStats_EmptyDatabase`, `TestStats_WithEvents` (insert sample events, verify output contains counts), `TestStats_FormatTable`. +- [x] T067 [US5] Create `cmd/replicator/stats.go` — implement `statsCmd() *cobra.Command` and `runStats()`. + +### 4C — Query Command (US5) + +- [x] T068 [P] [US5] Implement preset queries in `internal/query/presets.go` — define preset SQL queries as Go constants: `AgentActivity24h`, `CellsByStatus`, `SwarmCompletionRate`, `RecentEvents`. Implement `Run(store *db.Store, presetName string, w io.Writer) error` that executes the named query and formats results. +- [x] T069 [P] [US5] Add tests in `internal/query/presets_test.go` — `TestQuery_AgentActivity`, `TestQuery_CellsByStatus`, `TestQuery_UnknownPreset` (returns error), `TestQuery_FormatOutput`. +- [x] T070 [US5] Create `cmd/replicator/query.go` — implement `queryCmd() *cobra.Command` with preset name as positional arg. List available presets with `--list` flag. + +### 4D — Setup Command (US5) + +- [x] T071 [US5] Create `cmd/replicator/setup.go` — implement `setupCmd() *cobra.Command` and `runSetup()`. Create config dir `~/.config/swarm-tools/` if not exists, initialize database, verify git is installed. Print status for each step. + +### 4E — Wire Up Phase 4 + Checkpoint + +- [x] T072 Register new commands in `cmd/replicator/main.go` — add `root.AddCommand(doctorCmd())`, `root.AddCommand(statsCmd())`, `root.AddCommand(queryCmd())`, `root.AddCommand(setupCmd())`. +- [x] T073 Phase 4 checkpoint: run `make check`. Verify `time ./bin/replicator version` completes in <50ms. Verify `./bin/replicator doctor` runs all checks. All CLI commands have tests. + +--- + +## Phase 5: Parity Testing + +**User Story**: US6 (Parity Verification) +**Requirements**: FR-018, FR-019 +**Gate**: Parity report shows 100% shape match for all implemented tools (SC-003) + +### 5A — Shape Comparison Engine (US6) + +- [x] T074 [P] [US6] Create `test/parity/shape.go` — implement `ShapeMatch(expected, actual json.RawMessage) (bool, []Difference)`. `Difference{Path, ExpectedType, ActualType}`. Recursive JSON shape comparison: objects compare keys and recurse on values, arrays compare first element shape, primitives compare type (string/number/bool/null). Path uses JSONPath notation (e.g., `$.content[0].text`). +- [x] T075 [P] [US6] Add tests in `test/parity/shape_test.go` — `TestShapeMatch_IdenticalObjects`, `TestShapeMatch_TypeMismatch`, `TestShapeMatch_MissingField`, `TestShapeMatch_ExtraField`, `TestShapeMatch_NestedDifference`, `TestShapeMatch_ArrayElementShape`, `TestShapeMatch_EmptyObjects`. + +### 5B — Fixture Capture + Parity Harness (US6) + +- [x] T076 [US6] Create `test/parity/fixtures/` directory structure — one JSON file per tool family: `hive.json`, `swarmmail.json`, `swarm.json`, `memory.json`. Each file contains `{toolName: {request: {...}, typescript_response: {...}}}` captured from the TypeScript cyborg-swarm server. +- [x] T077 [US6] Create `test/parity/parity_test.go` — build tag `//go:build parity`. Iterate over fixture files, for each tool: start Go MCP server in-process, send fixture request, compare response shape against TypeScript fixture using `ShapeMatch()`. Report pass/fail per tool. +- [x] T078 [US6] Implement parity report generation in `test/parity/report.go` — `GenerateReport(results []ToolResult, w io.Writer)`. Output table: tool name, status (✓/✗), differences (if any). Summary line: `X/Y tools match (Z%)`. + +### 5C — Phase 5 Checkpoint + +- [x] T079 Phase 5 checkpoint: run `go test -tags parity ./test/parity/... -count=1`. Parity report shows shape match for all implemented tools. Generate report to `test/parity/report.txt`. +- [x] T080 Final validation: run `make check` (all unit/integration tests), verify binary size < 20MB (`ls -lh bin/replicator`), verify tool count matches expected total. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1**: No external dependencies — extends existing patterns +- **Phase 2**: Depends on Phase 1 (swarm tools use hive cells and swarmmail) +- **Phase 3**: Depends on Phase 1 (memory tools wire into same server). Can run in parallel with Phase 2 if desired. +- **Phase 4**: Depends on Phase 1 (doctor checks database). Can run in parallel with Phase 2/3. +- **Phase 5**: Depends on all other phases (parity tests cover all tools) + +### Within-Phase Parallelism + +- **Phase 1**: Tasks T005–T006 (epic) ∥ T007–T009 (start/ready) ∥ T021–T026 (swarmmail domain) — different packages +- **Phase 2**: Tasks T031–T034 (gitutil) ∥ T035–T038 (decompose) ∥ T039–T040 (spawn) ∥ T041–T042 (progress) — different packages +- **Phase 3**: Tasks T052–T053 (proxy) ∥ T056–T057 (deprecated) — different files in same package +- **Phase 4**: Tasks T062–T063 (doctor) ∥ T065–T066 (stats) ∥ T068–T069 (query) — different packages + +### Cross-Phase Dependencies + +- T029 (wire swarmmail) depends on T027 (swarmmail tools registration) +- T050 (wire swarm) depends on T049 (swarm tools registration) +- T059 (wire memory) depends on T058 (memory tools registration) + T060 (config) +- T072 (wire CLI) depends on T064, T067, T070, T071 (CLI commands) +- T076–T079 (parity) depends on all tool implementations + +### Parallel Opportunities (Swarm Workers) + +``` +Worker A: T005–T006 (hive/epic.go) +Worker B: T007–T009 (hive/cells.go additions) +Worker C: T021–T026 (swarmmail/ package) +Worker D: T010–T013 (hive/sync.go + session.go) +``` + +After domain logic completes, tool registration (T014–T020, T027–T028) and wiring (T029) are sequential. + + diff --git a/test/parity/fixtures/hive.json b/test/parity/fixtures/hive.json new file mode 100644 index 0000000..ba887bb --- /dev/null +++ b/test/parity/fixtures/hive.json @@ -0,0 +1,38 @@ +{ + "hive_cells": { + "request": {"name": "hive_cells", "arguments": {}}, + "typescript_response": {"content": [{"type": "text", "text": "[]"}]} + }, + "hive_cells_with_data": { + "request": {"name": "hive_cells", "arguments": {"status": "open"}}, + "typescript_response": {"content": [{"type": "text", "text": "[{\"id\":\"cell-abc123\",\"title\":\"Test task\",\"description\":\"\",\"type\":\"task\",\"status\":\"open\",\"priority\":1,\"parent_id\":null,\"created_at\":\"2025-01-01T00:00:00Z\",\"updated_at\":\"2025-01-01T00:00:00Z\"}]"}]} + }, + "hive_create": { + "request": {"name": "hive_create", "arguments": {"title": "New task", "type": "task", "priority": 1}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"id\":\"cell-abc123\",\"title\":\"New task\",\"description\":\"\",\"type\":\"task\",\"status\":\"open\",\"priority\":1,\"parent_id\":null,\"created_at\":\"2025-01-01T00:00:00Z\",\"updated_at\":\"2025-01-01T00:00:00Z\"}"}]} + }, + "hive_close": { + "request": {"name": "hive_close", "arguments": {"id": "cell-abc123", "reason": "done"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"status\":\"closed\"}"}]} + }, + "hive_update": { + "request": {"name": "hive_update", "arguments": {"id": "cell-abc123", "status": "in_progress"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"status\":\"updated\"}"}]} + }, + "hive_create_epic": { + "request": {"name": "hive_create_epic", "arguments": {"epic_title": "Test Epic", "subtasks": [{"title": "Sub 1"}, {"title": "Sub 2"}]}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"epic\":{\"id\":\"cell-epic123\",\"title\":\"Test Epic\",\"description\":\"\",\"type\":\"epic\",\"status\":\"open\",\"priority\":1,\"parent_id\":null,\"created_at\":\"2025-01-01T00:00:00Z\",\"updated_at\":\"2025-01-01T00:00:00Z\"},\"subtasks\":[{\"id\":\"cell-sub1\",\"title\":\"Sub 1\",\"type\":\"task\",\"status\":\"open\",\"priority\":1,\"parent_id\":\"cell-epic123\",\"created_at\":\"2025-01-01T00:00:00Z\",\"updated_at\":\"2025-01-01T00:00:00Z\"}]}"}]} + }, + "hive_query": { + "request": {"name": "hive_query", "arguments": {}}, + "typescript_response": {"content": [{"type": "text", "text": "[]"}]} + }, + "hive_start": { + "request": {"name": "hive_start", "arguments": {"id": "cell-abc123"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"status\":\"in_progress\"}"}]} + }, + "hive_ready": { + "request": {"name": "hive_ready", "arguments": {}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"message\":\"no ready cells\"}"}]} + } +} diff --git a/test/parity/fixtures/memory.json b/test/parity/fixtures/memory.json new file mode 100644 index 0000000..5fcd65e --- /dev/null +++ b/test/parity/fixtures/memory.json @@ -0,0 +1,14 @@ +{ + "hivemind_store": { + "request": {"name": "hivemind_store", "arguments": {"information": "test learning", "tags": "test"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"error\":\"dewey unavailable\",\"code\":\"DEWEY_UNAVAILABLE\",\"message\":\"Dewey semantic search is not available. Memory operations require a running Dewey instance.\"}"}]} + }, + "hivemind_find": { + "request": {"name": "hivemind_find", "arguments": {"query": "test"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"error\":\"dewey unavailable\",\"code\":\"DEWEY_UNAVAILABLE\",\"message\":\"Dewey semantic search is not available. Memory operations require a running Dewey instance.\"}"}]} + }, + "hivemind_get": { + "request": {"name": "hivemind_get", "arguments": {"id": "test-id"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"deprecated\":true,\"tool\":\"hivemind_get\",\"message\":\"hivemind_get is deprecated. Use dewey_get_page instead.\",\"replacement\":\"dewey_get_page\"}"}]} + } +} diff --git a/test/parity/fixtures/swarm.json b/test/parity/fixtures/swarm.json new file mode 100644 index 0000000..1d4aa60 --- /dev/null +++ b/test/parity/fixtures/swarm.json @@ -0,0 +1,18 @@ +{ + "swarm_init": { + "request": {"name": "swarm_init", "arguments": {"project_path": "/tmp/test"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"status\":\"initialized\",\"project_path\":\"/tmp/test\",\"isolation\":\"reservation\",\"timestamp\":\"2025-01-01T00:00:00Z\"}"}]} + }, + "swarm_decompose": { + "request": {"name": "swarm_decompose", "arguments": {"task": "Build a feature"}}, + "typescript_response": {"content": [{"type": "text", "text": "## Task Decomposition"}]} + }, + "swarm_status": { + "request": {"name": "swarm_status", "arguments": {"epic_id": "cell-epic123", "project_key": "test"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"epic_id\":\"cell-epic123\",\"project_key\":\"test\",\"total\":0,\"counts\":{\"open\":0,\"in_progress\":0,\"blocked\":0,\"closed\":0},\"subtasks\":[]}"}]} + }, + "swarm_worktree_list": { + "request": {"name": "swarm_worktree_list", "arguments": {"project_path": "/tmp/test"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"worktrees\":[],\"count\":0}"}]} + } +} diff --git a/test/parity/fixtures/swarmmail.json b/test/parity/fixtures/swarmmail.json new file mode 100644 index 0000000..b634cd1 --- /dev/null +++ b/test/parity/fixtures/swarmmail.json @@ -0,0 +1,18 @@ +{ + "swarmmail_init": { + "request": {"name": "swarmmail_init", "arguments": {"project_path": "/tmp/test", "agent_name": "worker-1"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"id\":1,\"name\":\"worker-1\",\"project_path\":\"/tmp/test\",\"task_description\":\"\",\"status\":\"active\"}"}]} + }, + "swarmmail_send": { + "request": {"name": "swarmmail_send", "arguments": {"to": ["worker-2"], "subject": "test", "body": "hello"}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"status\":\"sent\"}"}]} + }, + "swarmmail_inbox": { + "request": {"name": "swarmmail_inbox", "arguments": {}}, + "typescript_response": {"content": [{"type": "text", "text": "[]"}]} + }, + "swarmmail_health": { + "request": {"name": "swarmmail_health", "arguments": {}}, + "typescript_response": {"content": [{"type": "text", "text": "{\"status\":\"healthy\",\"agents\":0,\"messages\":0,\"reservations\":0}"}]} + } +} diff --git a/test/parity/parity_test.go b/test/parity/parity_test.go new file mode 100644 index 0000000..062dcbf --- /dev/null +++ b/test/parity/parity_test.go @@ -0,0 +1,474 @@ +//go:build parity + +// Parity tests verify that the Go rewrite produces MCP tool responses with +// the same JSON shape as the TypeScript cyborg-swarm original. +// +// Run with: go test -tags parity ./test/parity/ -count=1 -v +// +// These tests are excluded from normal `go test ./...` via the build tag. +// They use an in-memory SQLite database and call tool Execute functions +// directly (no subprocess needed). +package parity + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "testing" + + "github.com/unbound-force/replicator/internal/db" + "github.com/unbound-force/replicator/internal/memory" + hivetools "github.com/unbound-force/replicator/internal/tools/hive" + memtools "github.com/unbound-force/replicator/internal/tools/memory" + "github.com/unbound-force/replicator/internal/tools/registry" + swarmtools "github.com/unbound-force/replicator/internal/tools/swarm" + swarmmailtools "github.com/unbound-force/replicator/internal/tools/swarmmail" +) + +// fixtureEntry represents a single tool's fixture data. +type fixtureEntry struct { + Request json.RawMessage `json:"request"` + TypeScriptResponse json.RawMessage `json:"typescript_response"` +} + +// fixtureFile maps tool names to their fixture entries. +type fixtureFile map[string]fixtureEntry + +// loadFixtures reads all JSON fixture files from the fixtures directory. +func loadFixtures(t *testing.T) map[string]fixtureEntry { + t.Helper() + + fixturesDir := filepath.Join("fixtures") + entries, err := os.ReadDir(fixturesDir) + if err != nil { + t.Fatalf("read fixtures dir: %v", err) + } + + all := make(map[string]fixtureEntry) + for _, entry := range entries { + if filepath.Ext(entry.Name()) != ".json" { + continue + } + + data, err := os.ReadFile(filepath.Join(fixturesDir, entry.Name())) + if err != nil { + t.Fatalf("read fixture %s: %v", entry.Name(), err) + } + + var ff fixtureFile + if err := json.Unmarshal(data, &ff); err != nil { + t.Fatalf("parse fixture %s: %v", entry.Name(), err) + } + + for name, entry := range ff { + all[name] = entry + } + } + + return all +} + +// setupRegistry creates an in-memory store and registers all tools. +func setupRegistry(t *testing.T) (*registry.Registry, *db.Store) { + t.Helper() + + store, err := db.OpenMemory() + if err != nil { + t.Fatalf("open memory db: %v", err) + } + + reg := registry.New() + + // Register all tool families. + hivetools.Register(reg, store) + swarmmailtools.Register(reg, store) + swarmtools.Register(reg, store) + + // Memory tools use a Dewey proxy client. For parity tests, we create + // a client pointing to a non-existent server -- the deprecated tools + // don't need a real connection, and the proxy tools will return + // DEWEY_UNAVAILABLE which is still a valid response shape. + memClient := memory.NewClient("http://localhost:0") + memtools.Register(reg, memClient) + + return reg, store +} + +// createTempGitRepo creates a temporary directory initialized as a git repo. +func createTempGitRepo(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + {"git", "commit", "--allow-empty", "-m", "init"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git setup %v: %v\n%s", args, err, out) + } + } + + return dir +} + +// wrapMCPResponse wraps a tool's string output in the MCP content envelope +// that the TypeScript version uses: {"content": [{"type": "text", "text": "..."}]} +func wrapMCPResponse(text string) json.RawMessage { + envelope := map[string]any{ + "content": []map[string]string{ + {"type": "text", "text": text}, + }, + } + data, _ := json.Marshal(envelope) + return data +} + +// extractTextFromMCP extracts the text field from an MCP content envelope. +func extractTextFromMCP(raw json.RawMessage) (json.RawMessage, error) { + var envelope struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, fmt.Errorf("unmarshal MCP envelope: %w", err) + } + if len(envelope.Content) == 0 { + return nil, fmt.Errorf("empty content array") + } + return json.RawMessage(envelope.Content[0].Text), nil +} + +func TestParity(t *testing.T) { + reg, store := setupRegistry(t) + defer store.Close() + + fixtures := loadFixtures(t) + if len(fixtures) == 0 { + t.Fatal("no fixtures loaded") + } + + // Create a temp git repo for worktree tools. + gitDir := createTempGitRepo(t) + + // Tools that need pre-existing data or special handling. + // These are tested in dedicated subtests below. + skipTools := map[string]string{ + "hive_cells_with_data": "requires pre-existing data, tested separately", + "hive_close": "requires pre-existing cell", + "hive_update": "requires pre-existing cell", + "hive_start": "requires pre-existing cell", + } + + // Tools that need argument rewriting (e.g., project_path). + pathRewriteTools := map[string]bool{ + "swarm_init": true, + "swarm_worktree_list": true, + } + + var results []ToolResult + + // Sort fixture names for deterministic execution order. + // This ensures hive_ready runs before hive_create (which would + // create cells and change the hive_ready response shape). + names := make([]string, 0, len(fixtures)) + for name := range fixtures { + names = append(names, name) + } + sort.Strings(names) + + // Run empty-state tools first: hive_cells, hive_query, hive_ready + // must run before any hive_create calls populate the database. + emptyStateTools := []string{"hive_cells", "hive_query", "hive_ready"} + for _, name := range emptyStateTools { + fixture, ok := fixtures[name] + if !ok { + continue + } + t.Run(name+"_empty", func(t *testing.T) { + result := runFixtureTool(t, reg, name, fixture, gitDir, pathRewriteTools) + results = append(results, result) + }) + } + + // Run remaining tools (excluding skip and already-run empty-state tools). + alreadyRun := map[string]bool{ + "hive_cells": true, + "hive_query": true, + "hive_ready": true, + } + for _, name := range names { + if _, skip := skipTools[name]; skip { + continue + } + if alreadyRun[name] { + continue + } + + fixture := fixtures[name] + t.Run(name, func(t *testing.T) { + result := runFixtureTool(t, reg, name, fixture, gitDir, pathRewriteTools) + results = append(results, result) + }) + } + + // Test tools that need pre-existing data. + t.Run("hive_close_with_data", func(t *testing.T) { + result := testToolWithSetup(t, reg, store, "hive_close", fixtures) + results = append(results, result) + }) + + t.Run("hive_update_with_data", func(t *testing.T) { + result := testToolWithSetup(t, reg, store, "hive_update", fixtures) + results = append(results, result) + }) + + t.Run("hive_start_with_data", func(t *testing.T) { + result := testToolWithSetup(t, reg, store, "hive_start", fixtures) + results = append(results, result) + }) + + t.Run("hive_cells_with_data", func(t *testing.T) { + result := testCellsWithData(t, reg, store, fixtures) + results = append(results, result) + }) + + // Generate the parity report. + var buf bytes.Buffer + GenerateReport(results, &buf) + t.Logf("\n%s", buf.String()) +} + +// runFixtureTool executes a single fixture tool test and returns the result. +func runFixtureTool(t *testing.T, reg *registry.Registry, name string, fixture fixtureEntry, gitDir string, pathRewriteTools map[string]bool) ToolResult { + t.Helper() + + // Extract tool name and arguments from the fixture request. + var req struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(fixture.Request, &req); err != nil { + t.Fatalf("parse request: %v", err) + } + + // Rewrite project_path for tools that need a real git repo. + args := req.Arguments + if pathRewriteTools[name] { + var argMap map[string]any + json.Unmarshal(args, &argMap) + if _, ok := argMap["project_path"]; ok { + argMap["project_path"] = gitDir + } + args, _ = json.Marshal(argMap) + } + + tool := reg.Get(req.Name) + if tool == nil { + t.Fatalf("tool %q not registered", req.Name) + } + + // Execute the Go tool. + goResult, err := tool.Execute(args) + if err != nil { + t.Logf("tool %s returned error (may be expected): %v", req.Name, err) + return ToolResult{ + Name: name, + Match: false, + Differences: []Difference{{ + Path: "$", + ExpectedType: "object", + ActualType: fmt.Sprintf("error: %v", err), + }}, + } + } + + // Wrap Go result in MCP envelope for comparison. + goMCP := wrapMCPResponse(goResult) + + // Compare MCP envelope shapes first. + match, diffs := ShapeMatch(fixture.TypeScriptResponse, goMCP) + + if match { + // Envelope matches. Now compare the inner text content shapes. + expectedText, err := extractTextFromMCP(fixture.TypeScriptResponse) + if err != nil { + t.Fatalf("extract expected text: %v", err) + } + actualText := json.RawMessage(goResult) + + // Only compare inner shapes if both are valid JSON. + // Some tools return plain strings (prompt generators). + if isJSON(expectedText) && isJSON(actualText) { + match, diffs = ShapeMatch(expectedText, actualText) + } + } + + if !match { + for _, d := range diffs { + t.Errorf("shape mismatch at %s: expected %s, got %s", + d.Path, d.ExpectedType, d.ActualType) + } + } + + return ToolResult{ + Name: name, + Match: match, + Differences: diffs, + } +} + +// testToolWithSetup creates a cell, then tests a tool that requires one. +func testToolWithSetup(t *testing.T, reg *registry.Registry, store *db.Store, toolName string, fixtures map[string]fixtureEntry) ToolResult { + t.Helper() + + fixture, ok := fixtures[toolName] + if !ok { + return ToolResult{ + Name: toolName, + Match: false, + Differences: []Difference{{ + Path: "$", + ExpectedType: "fixture", + ActualType: "missing", + }}, + } + } + + // Create a cell to operate on. + createTool := reg.Get("hive_create") + createResult, err := createTool.Execute(json.RawMessage(`{"title": "test cell for ` + toolName + `"}`)) + if err != nil { + t.Fatalf("create cell for %s: %v", toolName, err) + } + + // Extract the cell ID. + var cell struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(createResult), &cell); err != nil { + t.Fatalf("parse created cell: %v", err) + } + + // Build arguments with the real cell ID. + var origArgs map[string]any + var req struct { + Arguments json.RawMessage `json:"arguments"` + } + json.Unmarshal(fixture.Request, &req) + json.Unmarshal(req.Arguments, &origArgs) + origArgs["id"] = cell.ID + argsJSON, _ := json.Marshal(origArgs) + + // Execute the tool. + tool := reg.Get(toolName) + goResult, err := tool.Execute(argsJSON) + if err != nil { + return ToolResult{ + Name: toolName, + Match: false, + Differences: []Difference{{ + Path: "$", + ExpectedType: "object", + ActualType: fmt.Sprintf("error: %v", err), + }}, + } + } + + // Compare inner response shapes. + expectedText, _ := extractTextFromMCP(fixture.TypeScriptResponse) + actualText := json.RawMessage(goResult) + + match, diffs := ShapeMatch(expectedText, actualText) + if !match { + for _, d := range diffs { + t.Errorf("%s shape mismatch at %s: expected %s, got %s", + toolName, d.Path, d.ExpectedType, d.ActualType) + } + } + + return ToolResult{ + Name: toolName, + Match: match, + Differences: diffs, + } +} + +// testCellsWithData creates a cell and then queries for it. +func testCellsWithData(t *testing.T, reg *registry.Registry, store *db.Store, fixtures map[string]fixtureEntry) ToolResult { + t.Helper() + + fixture, ok := fixtures["hive_cells_with_data"] + if !ok { + return ToolResult{ + Name: "hive_cells_with_data", + Match: false, + Differences: []Difference{{ + Path: "$", + ExpectedType: "fixture", + ActualType: "missing", + }}, + } + } + + // Create a cell so the query returns data. + createTool := reg.Get("hive_create") + _, err := createTool.Execute(json.RawMessage(`{"title": "test cell for query"}`)) + if err != nil { + t.Fatalf("create cell for hive_cells_with_data: %v", err) + } + + // Query cells. + tool := reg.Get("hive_cells") + goResult, err := tool.Execute(json.RawMessage(`{"status": "open"}`)) + if err != nil { + return ToolResult{ + Name: "hive_cells_with_data", + Match: false, + Differences: []Difference{{ + Path: "$", + ExpectedType: "array", + ActualType: fmt.Sprintf("error: %v", err), + }}, + } + } + + // Compare array element shapes. + expectedText, _ := extractTextFromMCP(fixture.TypeScriptResponse) + actualText := json.RawMessage(goResult) + + match, diffs := ShapeMatch(expectedText, actualText) + if !match { + for _, d := range diffs { + t.Errorf("hive_cells_with_data shape mismatch at %s: expected %s, got %s", + d.Path, d.ExpectedType, d.ActualType) + } + } + + return ToolResult{ + Name: "hive_cells_with_data", + Match: match, + Differences: diffs, + } +} + +// isJSON returns true if the raw message is valid JSON (not a plain string +// that isn't JSON-encoded). +func isJSON(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var v any + return json.Unmarshal(raw, &v) == nil +} diff --git a/test/parity/report.go b/test/parity/report.go new file mode 100644 index 0000000..3a51ac9 --- /dev/null +++ b/test/parity/report.go @@ -0,0 +1,59 @@ +package parity + +import ( + "fmt" + "io" +) + +// ToolResult captures the parity test outcome for a single MCP tool. +type ToolResult struct { + Name string + Match bool + Differences []Difference +} + +// GenerateReport writes a human-readable parity report to w. +// +// Output format: +// +// Parity Report +// ============= +// hive_cells ✓ +// hive_create ✓ +// hivemind_find ✗ $.content[0].text: expected object, got string +// --- +// 22/23 tools match (95.7%) +func GenerateReport(results []ToolResult, w io.Writer) { + fmt.Fprintln(w, "Parity Report") + fmt.Fprintln(w, "=============") + + matched := 0 + for _, r := range results { + if r.Match { + fmt.Fprintf(w, "%-30s ✓\n", r.Name) + matched++ + } else { + // Show first difference inline, rest on subsequent lines. + for i, d := range r.Differences { + if i == 0 { + fmt.Fprintf(w, "%-30s ✗ %s: expected %s, got %s\n", + r.Name, d.Path, d.ExpectedType, d.ActualType) + } else { + fmt.Fprintf(w, "%-30s %s: expected %s, got %s\n", + "", d.Path, d.ExpectedType, d.ActualType) + } + } + } + } + + fmt.Fprintln(w, "---") + + total := len(results) + if total == 0 { + fmt.Fprintln(w, "0/0 tools match (0.0%)") + return + } + + pct := float64(matched) / float64(total) * 100 + fmt.Fprintf(w, "%d/%d tools match (%.1f%%)\n", matched, total, pct) +} diff --git a/test/parity/shape.go b/test/parity/shape.go new file mode 100644 index 0000000..db1a7a6 --- /dev/null +++ b/test/parity/shape.go @@ -0,0 +1,161 @@ +// Package parity provides shape comparison tools for verifying that the Go +// rewrite of cyborg-swarm produces responses with the same JSON structure +// as the TypeScript original. +// +// Shape comparison checks field names and types (string, number, boolean, +// null, object, array) without comparing values. Extra fields in the actual +// response are acceptable (compatible superset), but missing fields are +// reported as mismatches. +package parity + +import ( + "encoding/json" + "fmt" + "sort" +) + +// Difference describes a structural mismatch between expected and actual JSON. +type Difference struct { + Path string // JSONPath notation, e.g., "$.content[0].text" + ExpectedType string // "string", "number", "boolean", "null", "object", "array" + ActualType string // same set, or "missing" if the key is absent +} + +// ShapeMatch performs a recursive JSON shape comparison between expected and +// actual responses. It returns true if the actual response is a compatible +// superset of the expected shape (all expected fields present with matching +// types). Extra fields in actual are allowed and do not cause a mismatch. +// +// For arrays, only the first element's shape is compared (if non-empty). +// For primitives, only the type is compared (string/number/boolean/null). +func ShapeMatch(expected, actual json.RawMessage) (bool, []Difference) { + var diffs []Difference + compareShape("$", expected, actual, &diffs) + return len(diffs) == 0, diffs +} + +// jsonType returns the JSON type name for a raw value. +// Uses byte inspection for unambiguous types (null, boolean, string, array) +// and falls back to Unmarshal only for number vs object disambiguation. +func jsonType(raw json.RawMessage) string { + if len(raw) == 0 { + return "null" + } + + // Check for literal null first -- before any Unmarshal attempts, + // because json.Unmarshal(null, &map) succeeds with a nil map. + trimmed := string(raw) + if trimmed == "null" { + return "null" + } + + // Use first byte to disambiguate unambiguous types. + switch raw[0] { + case '"': + return "string" + case '[': + return "array" + case '{': + return "object" + case 't', 'f': + return "boolean" + } + + // Must be a number (digits, minus sign, etc.). + var n float64 + if json.Unmarshal(raw, &n) == nil { + return "number" + } + + return "unknown" +} + +// compareShape recursively compares JSON shapes, accumulating differences. +func compareShape(path string, expected, actual json.RawMessage, diffs *[]Difference) { + expType := jsonType(expected) + actType := jsonType(actual) + + // Type mismatch at this level. + if expType != actType { + *diffs = append(*diffs, Difference{ + Path: path, + ExpectedType: expType, + ActualType: actType, + }) + return + } + + switch expType { + case "object": + compareObjects(path, expected, actual, diffs) + case "array": + compareArrays(path, expected, actual, diffs) + // Primitives (string, number, boolean, null): type match is sufficient. + } +} + +// compareObjects compares two JSON objects by key set and recurses on values. +// Missing keys in actual are reported. Extra keys in actual are allowed. +func compareObjects(path string, expected, actual json.RawMessage, diffs *[]Difference) { + var expObj, actObj map[string]json.RawMessage + if err := json.Unmarshal(expected, &expObj); err != nil { + return + } + if err := json.Unmarshal(actual, &actObj); err != nil { + return + } + + // Sort keys for deterministic output. + keys := make([]string, 0, len(expObj)) + for k := range expObj { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, key := range keys { + childPath := fmt.Sprintf("%s.%s", path, key) + expVal := expObj[key] + + actVal, exists := actObj[key] + if !exists { + *diffs = append(*diffs, Difference{ + Path: childPath, + ExpectedType: jsonType(expVal), + ActualType: "missing", + }) + continue + } + + compareShape(childPath, expVal, actVal, diffs) + } + + // Extra keys in actual are OK -- compatible superset. + // We intentionally do NOT report them as differences. +} + +// compareArrays compares two JSON arrays by comparing the shape of their +// first elements (if both are non-empty). Empty arrays match any array. +func compareArrays(path string, expected, actual json.RawMessage, diffs *[]Difference) { + var expArr, actArr []json.RawMessage + if err := json.Unmarshal(expected, &expArr); err != nil { + return + } + if err := json.Unmarshal(actual, &actArr); err != nil { + return + } + + // If expected array is empty, any array matches. + if len(expArr) == 0 { + return + } + + // If expected has elements but actual is empty, that's still a shape match + // for the array itself -- we can't compare element shapes. + if len(actArr) == 0 { + return + } + + // Compare first element shapes. + elemPath := fmt.Sprintf("%s[0]", path) + compareShape(elemPath, expArr[0], actArr[0], diffs) +} diff --git a/test/parity/shape_test.go b/test/parity/shape_test.go new file mode 100644 index 0000000..28fe2c2 --- /dev/null +++ b/test/parity/shape_test.go @@ -0,0 +1,219 @@ +package parity + +import ( + "encoding/json" + "testing" +) + +func TestShapeMatch_IdenticalObjects(t *testing.T) { + expected := json.RawMessage(`{"name": "alice", "age": 30, "active": true}`) + actual := json.RawMessage(`{"name": "bob", "age": 25, "active": false}`) + + match, diffs := ShapeMatch(expected, actual) + if !match { + t.Errorf("expected match, got diffs: %+v", diffs) + } +} + +func TestShapeMatch_TypeMismatch(t *testing.T) { + expected := json.RawMessage(`{"count": 42}`) + actual := json.RawMessage(`{"count": "forty-two"}`) + + match, diffs := ShapeMatch(expected, actual) + if match { + t.Error("expected mismatch, got match") + } + if len(diffs) != 1 { + t.Fatalf("expected 1 diff, got %d: %+v", len(diffs), diffs) + } + if diffs[0].Path != "$.count" { + t.Errorf("expected path $.count, got %s", diffs[0].Path) + } + if diffs[0].ExpectedType != "number" { + t.Errorf("expected type number, got %s", diffs[0].ExpectedType) + } + if diffs[0].ActualType != "string" { + t.Errorf("expected actual type string, got %s", diffs[0].ActualType) + } +} + +func TestShapeMatch_MissingField(t *testing.T) { + expected := json.RawMessage(`{"id": "abc", "title": "hello", "status": "open"}`) + actual := json.RawMessage(`{"id": "xyz", "title": "world"}`) + + match, diffs := ShapeMatch(expected, actual) + if match { + t.Error("expected mismatch, got match") + } + if len(diffs) != 1 { + t.Fatalf("expected 1 diff, got %d: %+v", len(diffs), diffs) + } + if diffs[0].Path != "$.status" { + t.Errorf("expected path $.status, got %s", diffs[0].Path) + } + if diffs[0].ActualType != "missing" { + t.Errorf("expected actual type missing, got %s", diffs[0].ActualType) + } +} + +func TestShapeMatch_ExtraField(t *testing.T) { + // Extra fields in actual should still match -- compatible superset. + expected := json.RawMessage(`{"id": "abc"}`) + actual := json.RawMessage(`{"id": "xyz", "extra_field": "bonus", "another": 42}`) + + match, diffs := ShapeMatch(expected, actual) + if !match { + t.Errorf("expected match (extra fields OK), got diffs: %+v", diffs) + } +} + +func TestShapeMatch_NestedDifference(t *testing.T) { + expected := json.RawMessage(`{"user": {"name": "alice", "profile": {"bio": "hello"}}}`) + actual := json.RawMessage(`{"user": {"name": "bob", "profile": {"bio": 42}}}`) + + match, diffs := ShapeMatch(expected, actual) + if match { + t.Error("expected mismatch, got match") + } + if len(diffs) != 1 { + t.Fatalf("expected 1 diff, got %d: %+v", len(diffs), diffs) + } + if diffs[0].Path != "$.user.profile.bio" { + t.Errorf("expected path $.user.profile.bio, got %s", diffs[0].Path) + } + if diffs[0].ExpectedType != "string" { + t.Errorf("expected type string, got %s", diffs[0].ExpectedType) + } + if diffs[0].ActualType != "number" { + t.Errorf("expected actual type number, got %s", diffs[0].ActualType) + } +} + +func TestShapeMatch_ArrayElementShape(t *testing.T) { + expected := json.RawMessage(`{"items": [{"id": "a", "value": 1}]}`) + actual := json.RawMessage(`{"items": [{"id": "b", "value": 2}, {"id": "c", "value": 3}]}`) + + match, diffs := ShapeMatch(expected, actual) + if !match { + t.Errorf("expected match, got diffs: %+v", diffs) + } +} + +func TestShapeMatch_ArrayElementTypeMismatch(t *testing.T) { + expected := json.RawMessage(`{"items": [{"id": "a", "count": 1}]}`) + actual := json.RawMessage(`{"items": [{"id": "b", "count": "one"}]}`) + + match, diffs := ShapeMatch(expected, actual) + if match { + t.Error("expected mismatch, got match") + } + if len(diffs) != 1 { + t.Fatalf("expected 1 diff, got %d: %+v", len(diffs), diffs) + } + if diffs[0].Path != "$.items[0].count" { + t.Errorf("expected path $.items[0].count, got %s", diffs[0].Path) + } +} + +func TestShapeMatch_EmptyObjects(t *testing.T) { + expected := json.RawMessage(`{}`) + actual := json.RawMessage(`{}`) + + match, diffs := ShapeMatch(expected, actual) + if !match { + t.Errorf("expected match for empty objects, got diffs: %+v", diffs) + } +} + +func TestShapeMatch_EmptyObjectMatchesPopulated(t *testing.T) { + // Empty expected matches any object (no required fields). + expected := json.RawMessage(`{}`) + actual := json.RawMessage(`{"id": "abc", "name": "test"}`) + + match, diffs := ShapeMatch(expected, actual) + if !match { + t.Errorf("expected match (empty expected), got diffs: %+v", diffs) + } +} + +func TestShapeMatch_NullHandling(t *testing.T) { + // null matches null. + expected := json.RawMessage(`{"value": null}`) + actual := json.RawMessage(`{"value": null}`) + + match, diffs := ShapeMatch(expected, actual) + if !match { + t.Errorf("expected match for null values, got diffs: %+v", diffs) + } +} + +func TestShapeMatch_NullVsString(t *testing.T) { + expected := json.RawMessage(`{"value": "hello"}`) + actual := json.RawMessage(`{"value": null}`) + + match, diffs := ShapeMatch(expected, actual) + if match { + t.Error("expected mismatch (string vs null), got match") + } + if len(diffs) != 1 { + t.Fatalf("expected 1 diff, got %d: %+v", len(diffs), diffs) + } + if diffs[0].ExpectedType != "string" || diffs[0].ActualType != "null" { + t.Errorf("expected string/null mismatch, got %s/%s", diffs[0].ExpectedType, diffs[0].ActualType) + } +} + +func TestShapeMatch_EmptyArrays(t *testing.T) { + expected := json.RawMessage(`{"items": []}`) + actual := json.RawMessage(`{"items": []}`) + + match, diffs := ShapeMatch(expected, actual) + if !match { + t.Errorf("expected match for empty arrays, got diffs: %+v", diffs) + } +} + +func TestShapeMatch_EmptyExpectedArrayMatchesPopulated(t *testing.T) { + // Empty expected array matches any array. + expected := json.RawMessage(`{"items": []}`) + actual := json.RawMessage(`{"items": [{"id": "abc"}]}`) + + match, diffs := ShapeMatch(expected, actual) + if !match { + t.Errorf("expected match (empty expected array), got diffs: %+v", diffs) + } +} + +func TestShapeMatch_TopLevelArray(t *testing.T) { + expected := json.RawMessage(`[{"id": "a", "name": "test"}]`) + actual := json.RawMessage(`[{"id": "b", "name": "other"}]`) + + match, diffs := ShapeMatch(expected, actual) + if !match { + t.Errorf("expected match for top-level arrays, got diffs: %+v", diffs) + } +} + +func TestShapeMatch_TopLevelPrimitive(t *testing.T) { + expected := json.RawMessage(`"hello"`) + actual := json.RawMessage(`"world"`) + + match, diffs := ShapeMatch(expected, actual) + if !match { + t.Errorf("expected match for string primitives, got diffs: %+v", diffs) + } +} + +func TestShapeMatch_MultipleDifferences(t *testing.T) { + expected := json.RawMessage(`{"a": "str", "b": 42, "c": true}`) + actual := json.RawMessage(`{"a": 1, "b": "wrong"}`) + + match, diffs := ShapeMatch(expected, actual) + if match { + t.Error("expected mismatch, got match") + } + // a: string vs number, b: number vs string, c: boolean vs missing + if len(diffs) != 3 { + t.Fatalf("expected 3 diffs, got %d: %+v", len(diffs), diffs) + } +}