diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..1689554 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "agent-pool", + "description": "Expert pool — delegate questions and tasks to domain specialists via a mixture-of-experts model", + "author": { + "name": "Cameron Sjo", + "url": "https://github.com/cameronsjo" + }, + "repository": "https://github.com/cameronsjo/agent-pool", + "license": "MIT", + "keywords": [ + "agent-pool", + "experts", + "mixture-of-experts", + "delegation", + "multi-agent", + "mcp", + "concierge", + "architect" + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbd4313..7092c0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,25 @@ jobs: name: coverage-report path: coverage.out retention-days: 7 + + build: + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, darwin] + goarch: [amd64, arm64] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + mkdir -p bin + go build -ldflags="-s -w" -o bin/agent-pool-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/agent-pool diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..30dee17 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, darwin] + goarch: [amd64, arm64] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + mkdir -p dist + go build -ldflags="-s -w" -o dist/agent-pool-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/agent-pool + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: agent-pool-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/agent-pool-* + + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: dist/agent-pool-* diff --git a/plugin/.mcp.json b/.mcp.json similarity index 100% rename from plugin/.mcp.json rename to .mcp.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6157e2a..fefeca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,97 @@ # Changelog -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +All notable changes to Agent Pool are documented here. ## [Unreleased] -### Added +## [0.9.0] — 2026-04-07 + +Formulas and operational hardening. + +- Formula parsing (`internal/formula/`) with TOML templates, DAG validation +- `instantiate_formula` architect MCP tool for bulk task creation +- Config hot-reload via fsnotify on `pool.toml` +- `EventConfigReloaded` event type +- `formulas/` directory in pool structure + +## [0.8.0] — 2026-04-06 + +Researcher role for knowledge curation and enrichment. + +- Researcher MCP tools: `enrich_state`, `write_expert_state`, `promote_pattern`, `read_expert_state`, `read_expert_logs`, `list_experts` (researcher variant) +- Daemon curation scheduling with configurable intervals +- Pattern promotion: durable patterns graduate from `state.md` to `identity.md` +- Researcher identity and prompt assembly +- Claude Code plugin: added `pool-research` skill + +## [0.7.0] — 2026-04-06 + +Shared experts and multi-pool foundations. + +- `~/.agent-pool/experts/` for cross-project shared experts +- `shared.include` config in `pool.toml` +- Project overlay via `shared-state/{name}/` for pool-specific context +- Shared expert directory resolution in mail routing +- `list_experts` tool returns both pool-scoped and shared experts + +## [0.6.0] — 2026-04-05 + +Daemon lifecycle and observability, driven by dogfooding. + +- Unix domain socket for CLI-to-daemon communication +- `agent-pool stop` — graceful shutdown via socket +- `agent-pool status` — daemon health and task summary +- `agent-pool watch` — live event stream +- Structured event bus (`internal/daemon/events.go`) +- Graceful drain on SIGINT/SIGTERM with configurable timeout +- `daemon.log` file output by default + +## [0.5.0] — 2026-04-04 + +Concierge plugin — user-facing interface for the pool. + +- Concierge MCP tools: `dispatch`, `collect`, `ask_expert`, `submit_plan`, `check_status`, `list_experts` +- Claude Code plugin with skills: `pool-ask`, `pool-build`, `pool-status` +- `.mcp.json` for auto-registering concierge MCP server +- `concierge-identity.md` role prompt +- Non-blocking `dispatch` + `collect` pattern for parallel expert queries + +## [0.4.0] — 2026-04-03 + +Architect role — contracts, verification, and task delegation. + +- Architect MCP tools: `define_contract`, `send_task`, `verify_result`, `amend_contract` +- Versioned contract specs in `contracts/` directory +- Human approval gate (`internal/approval/`) for architect-proposed changes +- Role-aware MCP server (different tool sets per role) + +## [0.3.0] — 2026-04-02 + +Task board with dependency DAG. + +- `internal/taskboard/` — DAG-based task tracking +- Dependency evaluation (`EvaluateDeps`) with cycle detection +- Task states: pending, blocked, active, completed, failed, cancelled +- Cancel propagation through dependency chains +- Session timeout and health checks + +## [0.2.0] — 2026-04-01 + +MCP server and state management. + +- Expert MCP tools: `read_state`, `update_state`, `append_error`, `send_response`, `recall`, `search_index` +- Mail composition and routing via MCP +- Expert spawning with `claude -p` integration +- Pre-tool-use hooks for code ownership guards +- CLI wiring for `agent-pool mcp` subcommand + +## [0.1.0] — 2026-03-31 + +Expert lifecycle — the foundation. -- Project scaffold with Go module, directory structure, and Makefile -- Pool config parser (`pool.toml`) -- CLI entry point with `start`, `version`, and `help` commands +- Mail parsing with YAML frontmatter +- Filesystem-based message routing (postoffice model) +- Expert session spawning with identity, state, and error context +- Log capture and task indexing +- At-least-once delivery with crash-safe inbox handling +- `Spawner` interface for test injection diff --git a/CLAUDE.md b/CLAUDE.md index cdd3891..a4da624 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ Agent Pool builds ON Claude Code via external interfaces — it does NOT modify - **CLI**: `claude -p --output-format stream-json --model sonnet --allowedTools "..."` - **MCP server**: `agent-pool mcp --pool {name} --expert {name}` (experts) or `--role {architect|concierge}` (built-in roles) - **Hooks**: Stop → flush, PreToolUse → code ownership guard -- **Plugin**: `plugin/` — skills (`pool-ask`, `pool-build`, `pool-status`) + `.mcp.json` for concierge +- **Plugin**: repo root — skills (`pool-ask`, `pool-build`, `pool-status`, `pool-research`) + `.mcp.json` for concierge - **Env vars**: `AGENT_POOL_NAME`, `AGENT_POOL_EXPERT`, `AGENT_POOL_TASK_ID` ## Project Structure @@ -57,7 +57,10 @@ internal/ mail/ Message parsing, routing, delivery mcp/ MCP server (stdio, per-role tool sets) taskboard/ DAG-based task tracking with dependency evaluation -plugin/ Claude Code plugin (skills, MCP config, identity) +skills/ Claude Code plugin skills (pool-ask, pool-build, pool-status, pool-research) +.claude-plugin/ Plugin manifest for marketplace +.mcp.json MCP server config for concierge role +concierge-identity.md Concierge role identity docs/ plans/ Architecture and development plans prompts/ Version-specific development prompts @@ -77,7 +80,7 @@ make check # vet + lint + test ## Implementation Status -**v0.5 complete** — through Concierge Plugin. See `docs/plans/architecture.md` § Implementation Phasing for full v0.1–v0.8 roadmap. +**v0.9 complete** — through Formulas + Polish. See `docs/plans/architecture.md` § Implementation Phasing for full roadmap. | Version | Milestone | Key Additions | |---------|-----------|---------------| @@ -85,8 +88,10 @@ make check # vet + lint + test | v0.3 | Task Board | DAG dependencies, cancel/handoff, session timeout | | v0.4 | Architect | Contracts, approval gate, task delegation, verification | | v0.5 | Concierge | Concierge MCP tools, plugin scaffold, read/write path flows | - -Next: **v0.6** — Researcher + Curation +| v0.6 | Daemon Lifecycle | Unix socket, stop/status/watch, graceful drain | +| v0.7 | Shared Experts | Cross-project knowledge, multi-pool, project overlays | +| v0.8 | Researcher | Curation, pattern promotion, cold-start seeding | +| v0.9 | Formulas | Workflow templates, config hot-reload, hardening | ## Code Conventions diff --git a/README.md b/README.md index e428fde..d509cd2 100644 --- a/README.md +++ b/README.md @@ -31,26 +31,37 @@ Four roles coordinate the work: make build ln -sf $(pwd)/bin/agent-pool ~/bin/agent-pool -# Create a pool inside your project +# Initialize a pool in your project cd ~/Projects/my-project -mkdir -p .agent-pool/{postoffice,contracts,concierge/inbox,architect/inbox} -mkdir -p .agent-pool/experts/backend/{inbox,logs} +agent-pool init -# Configure -cat > .agent-pool/pool.toml << 'EOF' -[pool] -name = "my-project" -project_dir = "~/Projects/my-project" +# Add experts +agent-pool add backend +agent-pool add frontend --model opus -[architect] -model = "opus" +# Start the daemon +agent-pool start -[experts.backend] -model = "sonnet" -EOF +# Check daemon status +agent-pool status -# Start the daemon (auto-discovers .agent-pool/ from cwd) -agent-pool start +# Stop gracefully +agent-pool stop +``` + +### Claude Code Plugin + +Install from the marketplace: + +```bash +/plugin marketplace add cameronsjo/workbench +/plugin install agent-pool@cameronsjo +``` + +Or point to a local build: + +```bash +/plugin install /path/to/agent-pool ``` ## Architecture @@ -110,10 +121,10 @@ make check # vet + lint + test | **v0.3** | Complete | Task board — dependency DAG, cancellation, health checks | | **v0.4** | Complete | Architect — contracts, verification loop, role-aware MCP | | **v0.5** | Complete | Concierge plugin — MCP tools, skills, read/write path flows | -| v0.6 | Next | Daemon lifecycle — unix socket, stop/status/watch, graceful drain | -| v0.7 | Planned | Shared experts — cross-project knowledge, multi-pool | -| v0.8 | Planned | Researcher — curation, cold-start seeding | -| v0.9 | Planned | Formulas — workflow templates, operational hardening | +| **v0.6** | Complete | Daemon lifecycle — unix socket, stop/status/watch, graceful drain | +| **v0.7** | Complete | Shared experts — cross-project knowledge, multi-pool | +| **v0.8** | Complete | Researcher — curation, cold-start seeding, pattern promotion | +| **v0.9** | Complete | Formulas — workflow templates, config hot-reload | ## License diff --git a/cmd/agent-pool/main.go b/cmd/agent-pool/main.go index 74a1c53..0914e3a 100644 --- a/cmd/agent-pool/main.go +++ b/cmd/agent-pool/main.go @@ -4,7 +4,10 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" + "regexp" + "sort" "io" "log/slog" "net" @@ -30,6 +33,12 @@ func main() { } switch os.Args[1] { + case "init": + cmdInit() + case "add": + cmdAdd() + case "list": + cmdList() case "start": cmdStart() case "stop": @@ -47,7 +56,7 @@ func main() { case "seed": cmdSeed() case "version": - fmt.Println("agent-pool v0.6.0-dev") + fmt.Println("agent-pool v0.9.0") case "help", "--help", "-h": printUsage() default: @@ -57,6 +66,240 @@ func main() { } } +func cmdInit() { + poolDir := ".agent-pool" + if len(os.Args) > 2 { + poolDir = os.Args[2] + } + poolDir = expandTilde(poolDir) + + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + poolName := filepath.Base(cwd) + if err := initPool(poolDir, poolName, cwd); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Initialized pool %q in %s\n", poolName, poolDir) + fmt.Println() + fmt.Println("Next steps:") + fmt.Println(" 1. Add experts:") + fmt.Println(" agent-pool add backend") + fmt.Println(" agent-pool add frontend --model opus") + fmt.Println() + fmt.Println(" 2. Start the daemon:") + fmt.Println(" agent-pool start") +} + +func cmdAdd() { + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "usage: agent-pool add [--model ]\n") + os.Exit(1) + } + + expertName := os.Args[2] + flags := parseFlagsFromArgs(os.Args[3:], "model") + model := flags["model"] + if model != "" && strings.HasPrefix(model, "-") { + fmt.Fprintf(os.Stderr, "error: --model requires a value\n") + os.Exit(1) + } + + poolDir, err := config.DiscoverPoolDir("") + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if err := addExpert(poolDir, expertName, model); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if model == "" { + fmt.Printf("Added expert %q (using default model)\n", expertName) + } else { + fmt.Printf("Added expert %q (model: %s)\n", expertName, model) + } +} + +// addExpert appends an expert section to pool.toml and creates its directories. +func addExpert(poolDir, name, model string) error { + // Validate name: must be alphanumeric, hyphens, or underscores (safe for TOML keys and filenames) + validName := regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + if !validName.MatchString(name) { + return fmt.Errorf("invalid expert name %q: must contain only letters, digits, hyphens, or underscores", name) + } + if config.BuiltinRoleNames[name] { + return fmt.Errorf("%q is a built-in role, not an expert name", name) + } + + // Check not already in config + cfg, err := config.LoadPool(poolDir) + if err != nil { + return fmt.Errorf("loading pool config: %w", err) + } + if _, exists := cfg.Experts[name]; exists { + return fmt.Errorf("expert %q already exists in pool config", name) + } + + // Append to pool.toml + tomlPath := filepath.Join(poolDir, "pool.toml") + f, err := os.OpenFile(tomlPath, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("opening %s: %w", tomlPath, err) + } + defer f.Close() + + section := fmt.Sprintf("\n[experts.%s]\n", name) + if model != "" { + section += fmt.Sprintf("model = %q\n", model) + } + if _, err := f.WriteString(section); err != nil { + return fmt.Errorf("writing to %s: %w", tomlPath, err) + } + + // Create directories + expertBase := filepath.Join(poolDir, "experts", name) + for _, sub := range []string{"inbox", "logs"} { + if err := os.MkdirAll(filepath.Join(expertBase, sub), 0o755); err != nil { + return fmt.Errorf("creating expert directory: %w", err) + } + } + + return nil +} + +func cmdList() { + poolDir, err := config.DiscoverPoolDir("") + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + cfg, err := config.LoadPool(poolDir) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + listExperts(poolDir, cfg) +} + +// listExperts prints a formatted table of pool-scoped and shared experts. +func listExperts(poolDir string, cfg *config.PoolConfig) { + type expertInfo struct { + Name string + Model string + Scope string + Has []string // which state files exist + } + + var experts []expertInfo + + // Pool-scoped experts (sorted for stable output) + expertNames := make([]string, 0, len(cfg.Experts)) + for name := range cfg.Experts { + expertNames = append(expertNames, name) + } + sort.Strings(expertNames) + for _, name := range expertNames { + sec := cfg.Experts[name] + model := sec.Model + if model == "" { + model = cfg.Defaults.Model + } + info := expertInfo{Name: name, Model: model, Scope: "pool"} + expertDir := filepath.Join(poolDir, "experts", name) + for _, file := range []string{"identity.md", "state.md", "errors.md"} { + if _, err := os.Stat(filepath.Join(expertDir, file)); err == nil { + info.Has = append(info.Has, strings.TrimSuffix(file, ".md")) + } + } + experts = append(experts, info) + } + + // Shared experts + for _, name := range cfg.Shared.Include { + model := cfg.Defaults.Model + info := expertInfo{Name: name, Model: model, Scope: "shared"} + sharedDir, err := config.SharedExpertDir(name) + if err == nil { + for _, file := range []string{"identity.md", "state.md", "errors.md"} { + if _, err := os.Stat(filepath.Join(sharedDir, file)); err == nil { + info.Has = append(info.Has, strings.TrimSuffix(file, ".md")) + } + } + } + experts = append(experts, info) + } + + if len(experts) == 0 { + fmt.Println("No experts configured. Add one with:") + fmt.Println(" agent-pool add ") + return + } + + fmt.Printf("%-20s %-10s %-8s %s\n", "EXPERT", "MODEL", "SCOPE", "STATE") + fmt.Printf("%-20s %-10s %-8s %s\n", "------", "-----", "-----", "-----") + for _, e := range experts { + state := "-" + if len(e.Has) > 0 { + state = strings.Join(e.Has, ", ") + } + fmt.Printf("%-20s %-10s %-8s %s\n", e.Name, e.Model, e.Scope, state) + } +} + +// initPool creates the pool directory structure and writes a minimal pool.toml. +func initPool(poolDir, poolName, projectDir string) error { + tomlPath := filepath.Join(poolDir, "pool.toml") + if _, err := os.Stat(tomlPath); err == nil { + return fmt.Errorf("%s already exists", tomlPath) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("checking %s: %w", tomlPath, err) + } + + dirs := []string{ + filepath.Join(poolDir, "postoffice"), + filepath.Join(poolDir, "contracts"), + filepath.Join(poolDir, "formulas"), + filepath.Join(poolDir, "architect", "inbox"), + filepath.Join(poolDir, "architect", "logs"), + filepath.Join(poolDir, "researcher", "inbox"), + filepath.Join(poolDir, "researcher", "logs"), + filepath.Join(poolDir, "concierge", "inbox"), + } + + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + } + + toml := fmt.Sprintf(`[pool] +name = %q +project_dir = %q + +[architect] +model = "opus" + +[defaults] +model = "sonnet" +`, poolName, projectDir) + + if err := os.WriteFile(tomlPath, []byte(toml), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", tomlPath, err) + } + + return nil +} + func cmdStart() { explicit := "" if len(os.Args) > 2 { @@ -544,6 +787,21 @@ func newStderrLogger() *slog.Logger { })) } +// expandTilde replaces a leading ~ with the user's home directory. +func expandTilde(path string) string { + if path == "~" || strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + return filepath.Join(home, path[2:]) + } + return path +} + // parseFlags extracts named --flag value pairs from os.Args[start:]. func parseFlags(start int, names ...string) map[string]string { return parseFlagsFromArgs(os.Args[start:], names...) @@ -661,26 +919,26 @@ func printUsage() { fmt.Println(`agent-pool — process supervisor for Claude Code expert sessions Usage: - agent-pool start [pool-dir] Start the daemon for a pool - agent-pool stop [pool-dir] Stop a running daemon - agent-pool status [pool-dir] Show daemon status - agent-pool watch [pool-dir] Stream daemon events - agent-pool mcp --pool --expert Start expert MCP server (stdio) - agent-pool mcp --pool --role Start built-in role MCP server - agent-pool seed --pool --expert Cold-start expert state via researcher - agent-pool flush --pool --expert --task Stop hook: verify state - agent-pool guard --pool --expert --path PreToolUse hook: ownership guard - agent-pool version Print version - agent-pool help Show this help - -Roles: - architect Contract definition, task delegation, verification - concierge User-facing coordination (read/write path tools) - researcher Enrichment and curation - -Examples: - agent-pool start ~/.agent-pool/pools/api-gateway - agent-pool stop - agent-pool status - agent-pool mcp --pool ./my-pool --role concierge`) + agent-pool init [pool-dir] Initialize a new pool (default: .agent-pool/) + agent-pool add [--model ] Add an expert to the pool + agent-pool list Show experts and their state + agent-pool start [pool-dir] Start the daemon + agent-pool stop [pool-dir] Stop the daemon + agent-pool status [pool-dir] Daemon health and task summary + agent-pool watch [pool-dir] Stream daemon events live + agent-pool seed --expert Cold-start an expert via researcher + agent-pool version Print version + agent-pool help Show this help + +Getting started: + agent-pool init Create a pool in the current project + agent-pool add backend Add a backend expert + agent-pool add frontend --model opus Add a frontend expert on Opus + agent-pool start Start the daemon + +Internal (hooks and plumbing): + agent-pool mcp --pool --expert Expert MCP server (stdio) + agent-pool mcp --pool --role Built-in role MCP server + agent-pool flush --pool --expert --task Stop hook + agent-pool guard --pool --expert --path Ownership guard`) } diff --git a/cmd/agent-pool/main_test.go b/cmd/agent-pool/main_test.go index f6c8554..db572be 100644 --- a/cmd/agent-pool/main_test.go +++ b/cmd/agent-pool/main_test.go @@ -8,11 +8,28 @@ // - Unknown flags → ignored // - Flag at end with no value → ignored // - Repeated flag → last value wins +// +// initPool: +// - Fresh directory → creates dirs + pool.toml with correct content +// - Already exists → returns error +// - Generated TOML is parseable by config.LoadPool +// +// addExpert: +// - Adds expert to pool.toml + creates dirs +// - Duplicate name → error +// - Builtin role name → error +// - Custom model → written to TOML +// - No model → section without model line (uses defaults) package main import ( + "os" + "path/filepath" + "strings" "testing" + + "github.com/cameronsjo/agent-pool/internal/config" ) func TestParseFlagsFromArgs_AllPresent(t *testing.T) { @@ -93,3 +110,174 @@ func TestParseFlagsFromArgs_RepeatedFlag(t *testing.T) { t.Errorf("pool = %q, want %q (last value wins)", result["pool"], "second") } } + +func TestInitPool_CreatesStructure(t *testing.T) { + tmp := t.TempDir() + poolDir := filepath.Join(tmp, ".agent-pool") + + err := initPool(poolDir, "my-project", "/home/user/my-project") + if err != nil { + t.Fatalf("initPool: %v", err) + } + + // Verify directories + expectedDirs := []string{ + "postoffice", + "contracts", + "formulas", + "architect/inbox", + "architect/logs", + "researcher/inbox", + "researcher/logs", + "concierge/inbox", + } + for _, dir := range expectedDirs { + full := filepath.Join(poolDir, dir) + if info, err := os.Stat(full); err != nil || !info.IsDir() { + t.Errorf("expected directory %s to exist", dir) + } + } + + // Verify pool.toml content + data, err := os.ReadFile(filepath.Join(poolDir, "pool.toml")) + if err != nil { + t.Fatalf("reading pool.toml: %v", err) + } + content := string(data) + if !strings.Contains(content, `name = "my-project"`) { + t.Error("pool.toml missing pool name") + } + if !strings.Contains(content, `project_dir = "/home/user/my-project"`) { + t.Error("pool.toml missing project_dir") + } +} + +func TestInitPool_AlreadyExists(t *testing.T) { + tmp := t.TempDir() + poolDir := filepath.Join(tmp, ".agent-pool") + + // First init succeeds + if err := initPool(poolDir, "test", "/tmp"); err != nil { + t.Fatalf("first initPool: %v", err) + } + + // Second init fails + err := initPool(poolDir, "test", "/tmp") + if err == nil { + t.Fatal("expected error on second init, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("error = %q, want 'already exists'", err.Error()) + } +} + +func TestInitPool_GeneratesValidConfig(t *testing.T) { + tmp := t.TempDir() + poolDir := filepath.Join(tmp, ".agent-pool") + + if err := initPool(poolDir, "my-project", "/home/user/my-project"); err != nil { + t.Fatalf("initPool: %v", err) + } + + // The generated TOML should be parseable by config.LoadPool + cfg, err := config.LoadPool(poolDir) + if err != nil { + t.Fatalf("LoadPool on init output: %v", err) + } + if cfg.Pool.Name != "my-project" { + t.Errorf("pool name = %q, want %q", cfg.Pool.Name, "my-project") + } + if cfg.Architect.Model != "opus" { + t.Errorf("architect model = %q, want %q", cfg.Architect.Model, "opus") + } + if cfg.Defaults.Model != "sonnet" { + t.Errorf("defaults model = %q, want %q", cfg.Defaults.Model, "sonnet") + } +} + +func TestAddExpert_CreatesExpert(t *testing.T) { + tmp := t.TempDir() + poolDir := filepath.Join(tmp, ".agent-pool") + initPool(poolDir, "test", tmp) + + if err := addExpert(poolDir, "backend", ""); err != nil { + t.Fatalf("addExpert: %v", err) + } + + // Verify directories created + for _, sub := range []string{"inbox", "logs"} { + dir := filepath.Join(poolDir, "experts", "backend", sub) + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + t.Errorf("expected directory experts/backend/%s", sub) + } + } + + // Verify config is valid and includes the expert + cfg, err := config.LoadPool(poolDir) + if err != nil { + t.Fatalf("LoadPool after add: %v", err) + } + if _, ok := cfg.Experts["backend"]; !ok { + t.Error("expert 'backend' not found in loaded config") + } +} + +func TestAddExpert_WithModel(t *testing.T) { + tmp := t.TempDir() + poolDir := filepath.Join(tmp, ".agent-pool") + initPool(poolDir, "test", tmp) + + if err := addExpert(poolDir, "frontend", "opus"); err != nil { + t.Fatalf("addExpert: %v", err) + } + + cfg, err := config.LoadPool(poolDir) + if err != nil { + t.Fatalf("LoadPool: %v", err) + } + if cfg.Experts["frontend"].Model != "opus" { + t.Errorf("model = %q, want %q", cfg.Experts["frontend"].Model, "opus") + } +} + +func TestAddExpert_Duplicate(t *testing.T) { + tmp := t.TempDir() + poolDir := filepath.Join(tmp, ".agent-pool") + initPool(poolDir, "test", tmp) + + addExpert(poolDir, "backend", "") + err := addExpert(poolDir, "backend", "") + if err == nil { + t.Fatal("expected error for duplicate expert") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("error = %q, want 'already exists'", err.Error()) + } +} + +func TestAddExpert_InvalidName(t *testing.T) { + tmp := t.TempDir() + poolDir := filepath.Join(tmp, ".agent-pool") + initPool(poolDir, "test", tmp) + + for _, name := range []string{"has space", "has.dot", "has/slash", `has"quote`} { + err := addExpert(poolDir, name, "") + if err == nil { + t.Errorf("expected error for name %q, got nil", name) + } + } +} + +func TestAddExpert_BuiltinRole(t *testing.T) { + tmp := t.TempDir() + poolDir := filepath.Join(tmp, ".agent-pool") + initPool(poolDir, "test", tmp) + + err := addExpert(poolDir, "architect", "") + if err == nil { + t.Fatal("expected error for builtin role name") + } + if !strings.Contains(err.Error(), "built-in role") { + t.Errorf("error = %q, want 'built-in role'", err.Error()) + } +} diff --git a/plugin/concierge-identity.md b/concierge-identity.md similarity index 71% rename from plugin/concierge-identity.md rename to concierge-identity.md index 087c94f..2041e8d 100644 --- a/plugin/concierge-identity.md +++ b/concierge-identity.md @@ -22,6 +22,19 @@ You are the concierge — the user-facing coordinator in an expert pool. | `check_status` | Query taskboard for task progress | | `list_experts` | Discover available experts | +## Researcher + +The pool includes a researcher role that curates expert knowledge. You +don't interact with the researcher directly — it runs automatically via +the daemon. But you should know: + +- Expert state files (`state.md`, `errors.md`) are periodically curated + by the researcher to keep them focused and current. +- Patterns that prove durable get promoted to `identity.md` by the + researcher. +- If users ask about expert knowledge quality, explain the curation + lifecycle: logs → state → identity. + ## Principles 1. Know who knows what. Use `list_experts` to understand the pool. diff --git a/plugin/plugin.json b/plugin/plugin.json deleted file mode 100644 index c0765ac..0000000 --- a/plugin/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "agent-pool", - "description": "Expert pool \u2014 delegate questions and tasks to domain specialists via a mixture-of-experts model", - "version": "0.5.0" -} diff --git a/plugin/skills/pool-ask.md b/skills/pool-ask.md similarity index 100% rename from plugin/skills/pool-ask.md rename to skills/pool-ask.md diff --git a/plugin/skills/pool-build.md b/skills/pool-build.md similarity index 100% rename from plugin/skills/pool-build.md rename to skills/pool-build.md diff --git a/skills/pool-research.md b/skills/pool-research.md new file mode 100644 index 0000000..c0c495b --- /dev/null +++ b/skills/pool-research.md @@ -0,0 +1,45 @@ +--- +name: pool-research +description: Use when the user asks about expert knowledge quality, wants to trigger curation, or asks how expert knowledge evolves over time +--- + +# Pool Research — Knowledge Lifecycle + +You are the concierge. The user wants to understand or influence the +knowledge lifecycle in the pool. + +## Context + +The researcher role curates expert knowledge automatically: +- **State curation**: Compresses working memory, removes stale entries +- **Pattern promotion**: Graduates durable patterns from state to identity +- **Error analysis**: Identifies recurring failures and updates error guidance + +This runs on the daemon's curation schedule. You can report on it but +don't directly invoke researcher tools. + +## Workflow + +### 1. Check expert state health + +Use `list_experts` to see which experts exist. Then use `dispatch` to +ask each relevant expert to self-report their state quality: +- How large are their state files? +- When was their last task? +- Are there stale entries they're carrying? + +### 2. Report to the user + +Present a health summary: +- **Healthy**: Recent tasks, compact state, identity reflects expertise +- **Stale**: No recent tasks, bloated state, needs curation +- **New**: Minimal identity, still building knowledge + +### 3. Suggest actions + +- **For stale experts**: "The researcher will curate this on its next + pass. Tasks completed since last curation will inform the update." +- **For knowledge gaps**: "Dispatch a research question to seed this + area, then the researcher will consolidate the findings." +- **For quality concerns**: "Use `check_status` to review recent task + outcomes, then `dispatch` focused validation tasks to the expert." diff --git a/plugin/skills/pool-status.md b/skills/pool-status.md similarity index 100% rename from plugin/skills/pool-status.md rename to skills/pool-status.md