diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 12e9f5d..f825516 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -14,7 +14,6 @@ jobs: environment: staging env: INGESTION_IMAGE: git.d-ma.be/mathias/ingestion - ROUTING_IMAGE: git.d-ma.be/mathias/routing INFRA_REPO: git@git.d-ma.be:mathias/infra.git BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock steps: @@ -41,28 +40,6 @@ jobs: echo "Built and pushed ${INGESTION_IMAGE}:${IMAGE_TAG}" - - name: Build and push routing image - run: | - set -e - trap 'rm -f /tmp/routing-image.tar' EXIT - IMAGE_TAG="${{ github.sha }}" - echo "Building ${ROUTING_IMAGE}:${IMAGE_TAG}" - - buildctl --addr "${BUILDKIT_HOST}" build \ - --frontend dockerfile.v0 \ - --local context=. \ - --local dockerfile=. \ - --opt filename=Dockerfile.routing \ - --opt build-arg:VERSION="${IMAGE_TAG}" \ - --output type=oci,dest=/tmp/routing-image.tar - - skopeo copy \ - oci-archive:/tmp/routing-image.tar \ - docker://${ROUTING_IMAGE}:${IMAGE_TAG} \ - --dest-creds "${{ secrets.REGISTRY_CREDS }}" - - echo "Built and pushed ${ROUTING_IMAGE}:${IMAGE_TAG}" - - name: Update infra repo run: | set -e @@ -81,18 +58,14 @@ jobs: sed -i "s|git.d-ma.be/mathias/ingestion:.*|git.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \ "k3s/apps/supervisor/ingestion-deployment.yaml" - sed -i "s|git.d-ma.be/mathias/routing:.*|git.d-ma.be/mathias/routing:${IMAGE_TAG}|" \ - "k3s/apps/routing/deployment.yaml" - git config user.email "cd-bot@d-ma.be" git config user.name "CD Bot" - git add "k3s/apps/supervisor/ingestion-deployment.yaml" \ - "k3s/apps/routing/deployment.yaml" - git commit -m "chore(deploy): ingestion+routing → ${IMAGE_TAG}" + git add "k3s/apps/supervisor/ingestion-deployment.yaml" + git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}" GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \ git push - echo "Infra repo updated: ingestion+routing → ${IMAGE_TAG}" + echo "Infra repo updated: ingestion → ${IMAGE_TAG}" - name: Trigger Flux reconcile (immediate) run: | @@ -132,35 +105,3 @@ jobs: kubectl describe pods -n supervisor -l app=ingestion | tail -40 exit 1 } - - - name: Wait for Flux to apply new routing image - run: | - EXPECTED="git.d-ma.be/mathias/routing:${{ github.sha }}" - for i in $(seq 1 60); do - CURRENT=$(kubectl get deploy routing -n routing \ - -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "") - if [ "$CURRENT" = "$EXPECTED" ]; then - echo "✓ Flux applied routing image after ${i}s" - break - fi - sleep 1 - done - kubectl get deploy routing -n routing \ - -o jsonpath='{.spec.template.spec.containers[0].image}' \ - | grep -qx "$EXPECTED" \ - || { echo "✗ Flux did not apply routing image within 60s"; exit 1; } - - - name: Verify routing rollout - run: | - kubectl rollout status deployment/routing \ - --namespace routing \ - --timeout=120s \ - || { - echo "── pod status ──" - kubectl get pods -n routing -o wide - echo "── events ──" - kubectl get events -n routing --sort-by='.lastTimestamp' | tail -20 - echo "── describe ──" - kubectl describe pods -n routing -l app=routing | tail -40 - exit 1 - } diff --git a/Dockerfile.routing b/Dockerfile.routing deleted file mode 100644 index 946a602..0000000 --- a/Dockerfile.routing +++ /dev/null @@ -1,30 +0,0 @@ -# syntax=docker/dockerfile:1 - -# ── Build stage ─────────────────────────────────────────────────────────────── -FROM golang:1.26-bookworm AS builder - -ARG VERSION=dev -WORKDIR /src - -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \ - -o /out/routing ./cmd/routing - -# ── Runtime stage ───────────────────────────────────────────────────────────── -FROM gcr.io/distroless/base-debian12 - -COPY --from=builder /out/routing /usr/local/bin/routing -COPY config/ /app/config/ - -ENV SUPERVISOR_CONFIG_DIR=/app/config/supervisor -ENV ROUTING_PORT=3210 - -EXPOSE 3210 - -USER 65532:65532 - -ENTRYPOINT ["/usr/local/bin/routing"] diff --git a/ICEBOX.md b/ICEBOX.md new file mode 100644 index 0000000..436876c --- /dev/null +++ b/ICEBOX.md @@ -0,0 +1,49 @@ +# Icebox — retired code (recoverable, not destroyed) + +Per issue #75 (consolidate to a single harness), the routing-pod path was removed +from the live tree. It is **preserved and recoverable**, not deleted without trace. + +## What was iceboxed (2026-07-01, issue #75) + +| Path | Why | +|------|-----| +| `cmd/routing/` | The routing MCP-server binary; every skill call was wrapped through the broken pass-rate router (`wrap(skillName)`). | +| `internal/routing/` | Router / Fetcher / Policy / pass-rate. Signal is survivorship-biased and unusable as-is (infra#174). | +| `internal/skills/{review,debug,retrospective,trainer,project}/` | Skill handlers usable **only** through `cmd/routing` (verified: each imported solely by `cmd/routing`). | +| `Dockerfile.routing` | Built `cmd/routing` exclusively. | +| `.gitea/workflows/cd.yml` (routing steps only) | Removed the routing image build + infra image-bump + Flux-wait/rollout-verify for routing; **ingestion build/deploy is unchanged**. | + +## Why + +The live minimal harness is `cmd/hyperguild` + `brain-mcp` (+ `gitea-mcp` available) — +that is what ran the infra#170 loop-1 experiment (routing/injection machinery off) and +closed it twice. `cmd/hyperguild` has **zero** transitive dependency on `internal/routing` +or `cmd/routing`'s packages (it imports only `internal/tier`). The routing pass-rate signal +is being retired, not resurrected (fresh start, per infra#174). + +## How to recover + +Everything above is preserved at commit `00e5f62` under: + +- **tag** `icebox/cmd-routing-2026-07-01` +- **branch** `icebox/cmd-routing` + +```bash +# inspect +git checkout icebox/cmd-routing-2026-07-01 + +# restore specific packages onto a branch +git checkout icebox/cmd-routing-2026-07-01 -- cmd/routing internal/routing \ + internal/skills/review internal/skills/debug internal/skills/retrospective \ + internal/skills/trainer internal/skills/project Dockerfile.routing +``` + +## Deliberately NOT touched here (separate scope) + +- **Live k8s routing deployment** (`infra` repo, `k3s/apps/routing/`) still runs its last + image; CD no longer rebuilds/redeploys it. Tearing down that deployment is a separate + infra-repo task. +- **`internal/skills/{brain,org,sessionlog}/`** — kept per #75; already had no importer + (orphaned before this cut), harmless, compile + test green. +- **`config/supervisor/{review,debug,retrospective,trainer-*}.md`** — routing skill prompts, + now orphaned data; left in place (not code, no build impact). diff --git a/cmd/routing/main.go b/cmd/routing/main.go deleted file mode 100644 index d8b7028..0000000 --- a/cmd/routing/main.go +++ /dev/null @@ -1,170 +0,0 @@ -package main - -// The internal/skills/{debug,retrospective,review,trainer} packages imported -// below are also imported by cmd/supervisor. Plan 7 (supervisor retirement) -// MUST NOT delete these four packages — the routing pod is their second -// consumer. Plan 7 deletes only internal/skills/{tdd,spec,tier} (the skills -// that don't route to local), the supervisor binary, and supervisor manifests. -// See docs/superpowers/specs/2026-05-04-mode-2-routing-pod-design.md (Constraints). - -import ( - "context" - "log/slog" - "net/http" - "os" - "time" - - "github.com/mathiasbq/supervisor/internal/auth" - "github.com/mathiasbq/supervisor/internal/config" - iexec "github.com/mathiasbq/supervisor/internal/exec" - "github.com/mathiasbq/supervisor/internal/githubclient" - "github.com/mathiasbq/supervisor/internal/mcp" - "github.com/mathiasbq/supervisor/internal/mcpclient" - "github.com/mathiasbq/supervisor/internal/registry" - "github.com/mathiasbq/supervisor/internal/routing" - "github.com/mathiasbq/supervisor/internal/skills/debug" - "github.com/mathiasbq/supervisor/internal/skills/project" - "github.com/mathiasbq/supervisor/internal/skills/retrospective" - "github.com/mathiasbq/supervisor/internal/skills/review" - "github.com/mathiasbq/supervisor/internal/skills/trainer" -) - -func main() { - logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) - slog.SetDefault(logger) - - cfg, err := config.LoadRouting() - if err != nil { - logger.Error("config load failed", "err", err) - os.Exit(1) - } - - configDir := envOr("SUPERVISOR_CONFIG_DIR", "/app/config/supervisor") - mustRead := func(path string) string { - b, err := os.ReadFile(configDir + "/" + path) - if err != nil { - logger.Error("read prompt failed", "path", path, "err", err) - os.Exit(1) - } - return string(b) - } - - llm := iexec.NewLiteLLM(cfg.LiteLLMBaseURL, cfg.LiteLLMAPIKey, 0) - - router := &routing.Router{ - Fetcher: routing.NewFetcher(cfg.BrainURL, "7d", time.Duration(cfg.PassRateTTLSeconds)*time.Second), - Logger: routing.NewLogger(cfg.BrainURL, cfg.BrainMCPToken), - Policy: routing.Policy{Floor: cfg.RouteLocalFloor, Ceil: cfg.RouteLocalCeil}, - FastModel: cfg.FastModel, - ThinkingModel: cfg.ThinkingModel, - Complete: llm.Complete, - } - - // Skill packages call CompleteFunc(ctx, model, system, user) — no session_id - // or project_root in the signature. Rather than modifying every skill's API - // (and inflating Plan 6's blast radius), the routing pod logs every decision - // under a fixed session_id "_routing". Operators query - // `GET /pass-rate?skill=_routing&window=...` to inspect routing health. - const routingSessionID = "_routing" - wrap := func(skillName string) routing.CompleteFunc { - return func(ctx context.Context, _, system, user string) (string, int64, error) { - // The model param is ignored: the router picks the model based on policy. - return router.Run(ctx, routing.RunInput{ - Skill: skillName, - System: system, - User: user, - SessionID: routingSessionID, - ProjectRoot: "", - }) - } - } - - reg := registry.New() - reg.Register(review.New(review.Config{ - SkillPrompt: mustRead("review.md"), - DefaultModel: cfg.FastModel, - CompleteFunc: review.CompleteFunc(wrap("review")), - })) - reg.Register(debug.New(debug.Config{ - SkillPrompt: mustRead("debug.md"), - DefaultModel: cfg.FastModel, - CompleteFunc: debug.CompleteFunc(wrap("debug")), - })) - reg.Register(retrospective.New(retrospective.Config{ - SkillPrompt: mustRead("retrospective.md"), - DefaultModel: cfg.FastModel, - CompleteFunc: retrospective.CompleteFunc(wrap("retrospective")), - })) - reg.Register(trainer.New(trainer.Config{ - ReaderPrompt: mustRead("trainer-reader.md"), - WriterPrompt: mustRead("trainer-writer.md"), - DefaultModel: cfg.FastModel, - CompleteFunc: trainer.CompleteFunc(wrap("trainer")), - })) - - if cfg.GiteaMCPURL != "" { - mcpC, err := mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken) - if err != nil { - logger.Error("mcpclient init for project_create — GITEA_MCP_URL is set but GITEA_MCP_TOKEN is empty (check routing-secrets)", "err", err) - os.Exit(1) - } - var ghClient *githubclient.Client - if cfg.GitHubPAT != "" { - ghClient = githubclient.New(cfg.GitHubPAT) - } - reg.Register(project.New(project.Config{ - Client: mcpC, - GitHub: ghClient, - GiteaOwner: cfg.GiteaOwner, - GitHubOwner: cfg.GitHubOwner, - GitHubPAT: cfg.GitHubPAT, - InfraRepo: cfg.InfraRepo, - })) - logger.Info("project_create registered", "gitea_mcp_url", cfg.GiteaMCPURL, - "gitea_owner", cfg.GiteaOwner, "github_owner", cfg.GitHubOwner, - "infra_repo", cfg.InfraRepo, "github_pat_set", cfg.GitHubPAT != "") - } else { - logger.Info("project_create skipped — GITEA_MCP_URL not set") - } - - var validator *auth.Validator - if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" { - audience := os.Getenv("MCP_AUDIENCE") - v, err := auth.NewValidator(dexURL, audience) - if err != nil { - logger.Error("build jwt validator", "err", err) - os.Exit(1) - } - validator = v - logger.Info("jwt auth enabled", "issuer", dexURL) - } - - srv := mcp.NewServer(reg, cfg.MCPAuthToken, validator) - mux := http.NewServeMux() - mux.Handle("/mcp", srv) - mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" { - resourceURL := os.Getenv("MCP_RESOURCE_URL") - mux.HandleFunc("GET /.well-known/oauth-protected-resource", - auth.ProtectedResourceHandler(resourceURL, dexURL)) - } - - addr := ":" + cfg.Port - logger.Info("routing pod starting", "addr", addr, - "fast", cfg.FastModel, "thinking", cfg.ThinkingModel, - "floor", cfg.RouteLocalFloor, "ceil", cfg.RouteLocalCeil) - if err := http.ListenAndServe(addr, mux); err != nil { //nolint:gosec - logger.Error("server stopped", "err", err) - os.Exit(1) - } -} - -func envOr(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} diff --git a/cmd/routing/main_test.go b/cmd/routing/main_test.go deleted file mode 100644 index 20c3fc4..0000000 --- a/cmd/routing/main_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package main_test - -import ( - "context" - "encoding/json" - "io" - "net" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "strconv" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestRoutingPodEndToEnd boots the binary against fake LiteLLM + brain servers, -// calls tools/list and one tools/call, and verifies the brain saw a session_log POST. -func TestRoutingPodEndToEnd(t *testing.T) { - if testing.Short() { - t.Skip("end-to-end binary boot") - } - - var brainHits int - llm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "stub"}}}, - }) - })) - defer llm.Close() - - brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/pass-rate": - brainHits++ - _ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.95}) - case "/mcp": - brainHits++ - _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}}) - } - })) - defer brain.Close() - - port := freePort(t) - addr := "127.0.0.1:" + port - baseURL := "http://" + addr - - bin := buildRouting(t) - cmd := exec.Command(bin) - cmd.Env = []string{ - "ROUTING_PORT=" + port, - "LITELLM_BASE_URL=" + llm.URL, - "LITELLM_API_KEY=stub", - "BRAIN_URL=" + brain.URL, - "SUPERVISOR_CONFIG_DIR=../../config/supervisor", - "PATH=" + os.Getenv("PATH"), - "HOME=" + os.Getenv("HOME"), - } - require.NoError(t, cmd.Start()) - t.Cleanup(func() { _ = cmd.Process.Kill() }) - - require.NoError(t, waitForPort(t, addr, 30*time.Second)) - - resp := mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) - assert.Contains(t, resp, `"review"`) - assert.Contains(t, resp, `"debug"`) - assert.Contains(t, resp, `"retrospective"`) - assert.Contains(t, resp, `"trainer"`) - - resp = mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"review","arguments":{"project_root":"/tmp","files":["README.md"]}}}`) - _ = resp // shape varies by skill; we only need a 200 - - // Wait briefly for the async session_log to land. - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) && brainHits < 2 { - time.Sleep(50 * time.Millisecond) - } - assert.GreaterOrEqual(t, brainHits, 2, "expected at least one /pass-rate hit and one /mcp session_log hit") -} - -func buildRouting(t *testing.T) string { - t.Helper() - bin := t.TempDir() + "/routing" - out, err := exec.Command("go", "build", "-o", bin, "github.com/mathiasbq/supervisor/cmd/routing").CombinedOutput() - require.NoError(t, err, "build failed: %s", out) - return bin -} - -func waitForPort(_ *testing.T, addr string, dur time.Duration) error { - deadline := time.Now().Add(dur) - for time.Now().Before(deadline) { - c, err := http.Get("http://" + addr + "/healthz") //nolint:noctx - if err == nil { - _ = c.Body.Close() - return nil - } - conn, err := http.NewRequest(http.MethodPost, "http://"+addr+"/mcp", strings.NewReader(`{}`)) - if err == nil { - r, err := http.DefaultClient.Do(conn) - if err == nil { - _ = r.Body.Close() - return nil - } - } - time.Sleep(50 * time.Millisecond) - } - return context.DeadlineExceeded -} - -func mcpCall(t *testing.T, url, body string) string { - t.Helper() - r, err := http.Post(url, "application/json", strings.NewReader(body)) //nolint:noctx - require.NoError(t, err) - defer func() { _ = r.Body.Close() }() - raw, err := io.ReadAll(r.Body) - require.NoError(t, err) - return string(raw) -} - -// freePort grabs an OS-assigned TCP port and releases it. There is a small -// race window before the subprocess re-binds it, but it is acceptable for -// test isolation against a hardcoded port colliding with another test or -// stray process. -func freePort(t *testing.T) string { - t.Helper() - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - port := l.Addr().(*net.TCPAddr).Port - require.NoError(t, l.Close()) - return strconv.Itoa(port) -} diff --git a/internal/routing/hash.go b/internal/routing/hash.go deleted file mode 100644 index b08512b..0000000 --- a/internal/routing/hash.go +++ /dev/null @@ -1,21 +0,0 @@ -package routing - -import ( - "crypto/sha256" - "encoding/binary" -) - -// CanonicalHash returns a deterministic 64-bit hash of (system, user). -// Used to make sample-band routing decisions reproducible: identical input -// strings produce the same hash on every call, independent of process state. -// -// Inputs are joined with a 0x00 byte separator before hashing — distinguishes -// (system="ab", user="cd") from (system="abcd", user=""). -func CanonicalHash(system, user string) uint64 { - h := sha256.New() - h.Write([]byte(system)) - h.Write([]byte{0}) - h.Write([]byte(user)) - sum := h.Sum(nil) - return binary.BigEndian.Uint64(sum[:8]) -} diff --git a/internal/routing/hash_test.go b/internal/routing/hash_test.go deleted file mode 100644 index 2983d10..0000000 --- a/internal/routing/hash_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package routing_test - -import ( - "testing" - - "github.com/mathiasbq/supervisor/internal/routing" - "github.com/stretchr/testify/assert" -) - -func TestCanonicalHashDeterministic(t *testing.T) { - a := routing.CanonicalHash("system one", "user one") - b := routing.CanonicalHash("system one", "user one") - assert.Equal(t, a, b, "same inputs must produce same hash") -} - -func TestCanonicalHashDistinguishesInputs(t *testing.T) { - cases := [][2]string{ - {"sys", "user"}, - {"sys", "user2"}, - {"sys2", "user"}, - {"", "system\x00user"}, // separator collision attempt - {"system\x00user", ""}, - } - seen := make(map[uint64]bool) - for _, c := range cases { - h := routing.CanonicalHash(c[0], c[1]) - assert.False(t, seen[h], "collision on %v", c) - seen[h] = true - } -} - -func TestCanonicalHashLowBitDistribution(t *testing.T) { - // Sanity check: across 1000 distinct inputs, low-bit split is roughly even. - zeros, ones := 0, 0 - for i := 0; i < 1000; i++ { - h := routing.CanonicalHash("sys", string(rune('a'+(i%26)))+string(rune(i))) - if h&1 == 0 { - zeros++ - } else { - ones++ - } - } - // Allow ±15% deviation from 500/500. Tighter would be flaky on real data. - assert.InDelta(t, 500, zeros, 150) - assert.InDelta(t, 500, ones, 150) -} diff --git a/internal/routing/log.go b/internal/routing/log.go deleted file mode 100644 index 9f41ce2..0000000 --- a/internal/routing/log.go +++ /dev/null @@ -1,92 +0,0 @@ -package routing - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" -) - -// LogEntry describes a single routing decision to log via the brain MCP. -type LogEntry struct { - SessionID string - Skill string // the original skill the call routed (e.g., "review") - Decision string // "local" or "thinking" or "thinking_fallback" - Message string // free-form, e.g. "model=qwen35, pass_rate=0.94" - ProjectRoot string - DurationMs int64 - Failed bool // true → final_status: "fail"; false → "pass" -} - -// Logger posts session_log entries to a brain MCP at BrainURL + /mcp. -type Logger struct { - BrainURL string - Token string // bearer for the (auth-gated) ingestion /mcp; empty = no header - HTTP *http.Client -} - -// NewLogger creates a Logger with a 2-second HTTP timeout. token authenticates -// to the bearer-gated ingestion /mcp; an empty token sends no Authorization -// header (and silently 401s against a gated server — see brain -// mcpclient-empty-token-silent-401-envfrom-missing-key). -func NewLogger(brainURL, token string) *Logger { - return &Logger{ - BrainURL: brainURL, - Token: token, - HTTP: &http.Client{Timeout: 2 * time.Second}, - } -} - -// LogDecision posts a session_log MCP call. Errors are returned but the caller -// MUST NOT block real work on them — logging is best-effort. -func (l *Logger) LogDecision(ctx context.Context, e LogEntry) error { - // A completed routed call is a pass (liveness); only an execution error is a - // fail. There is no "skip" for routing — the prior default-to-"skip" meant a - // successful call never counted toward pass_rate, so the gate was unreachable. - status := "pass" - if e.Failed { - status = "fail" - } - payload := map[string]any{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": map[string]any{ - "name": "session_log", - "arguments": map[string]any{ - "session_id": e.SessionID, - // The real skill, so /pass-rate?skill=review|debug sees these - // records; routing decisions stay groupable via session_id "_routing". - "skill": e.Skill, - "phase": "decide", - "final_status": status, - "message": fmt.Sprintf("%s: %s — %s", e.Skill, e.Decision, e.Message), - "duration_ms": e.DurationMs, - "project_root": e.ProjectRoot, - }, - }, - } - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("log: marshal: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.BrainURL+"/mcp", bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("log: build request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - if l.Token != "" { - req.Header.Set("Authorization", "Bearer "+l.Token) - } - resp, err := l.HTTP.Do(req) - if err != nil { - return fmt.Errorf("log: request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("log: server returned status %d", resp.StatusCode) - } - return nil -} diff --git a/internal/routing/log_test.go b/internal/routing/log_test.go deleted file mode 100644 index 851a301..0000000 --- a/internal/routing/log_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package routing_test - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/mathiasbq/supervisor/internal/routing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLoggerLogDecision(t *testing.T) { - var captured map[string]any - var authHeader string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/mcp", r.URL.Path) - authHeader = r.Header.Get("Authorization") - body, _ := io.ReadAll(r.Body) - require.NoError(t, json.Unmarshal(body, &captured)) - _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": "ok"}}}}) - })) - defer srv.Close() - - l := routing.NewLogger(srv.URL, "test-token") - err := l.LogDecision(context.Background(), routing.LogEntry{ - SessionID: "sess-1", - Skill: "review", - Decision: "local", - Message: "model=qwen36, pass_rate=0.94", - ProjectRoot: "/home/x/proj", - DurationMs: 1234, - Failed: false, - }) - require.NoError(t, err) - - // Bug C fix: the POST authenticates to the bearer-gated ingestion /mcp. - assert.Equal(t, "Bearer test-token", authHeader) - - params := captured["params"].(map[string]any) - assert.Equal(t, "tools/call", captured["method"]) - assert.Equal(t, "session_log", params["name"]) - - args := params["arguments"].(map[string]any) - // Bug B fix: the record carries the real skill so /pass-rate?skill=review sees it. - assert.Equal(t, "review", args["skill"]) - assert.Equal(t, "decide", args["phase"]) - // Bug A fix: a successful routed call logs "pass", not "skip". - assert.Equal(t, "pass", args["final_status"]) - assert.Contains(t, args["message"].(string), "review: local") - // session grouping is preserved via session_id. - assert.Equal(t, "sess-1", args["session_id"]) - assert.Equal(t, "/home/x/proj", args["project_root"]) - assert.Equal(t, float64(1234), args["duration_ms"]) -} - -func TestLoggerLogFailure(t *testing.T) { - var captured map[string]any - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - _ = json.Unmarshal(body, &captured) - _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}}) - })) - defer srv.Close() - - l := routing.NewLogger(srv.URL, "test-token") - err := l.LogDecision(context.Background(), routing.LogEntry{ - SessionID: "s", Skill: "debug", Decision: "local", Message: "litellm down", Failed: true, - }) - require.NoError(t, err) - - args := captured["params"].(map[string]any)["arguments"].(map[string]any) - assert.Equal(t, "debug", args["skill"]) - assert.Equal(t, "fail", args["final_status"]) -} - -func TestLoggerOmitsAuthWhenTokenEmpty(t *testing.T) { - var authHeader string - hasAuth := false - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader = r.Header.Get("Authorization") - _, hasAuth = r.Header["Authorization"] - _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}}) - })) - defer srv.Close() - - l := routing.NewLogger(srv.URL, "") - require.NoError(t, l.LogDecision(context.Background(), routing.LogEntry{Skill: "review", SessionID: "_routing", Decision: "local"})) - assert.False(t, hasAuth, "no Authorization header should be set when token is empty") - assert.Equal(t, "", authHeader) -} - -func TestLoggerSurfacesUpstreamError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, "down", http.StatusBadGateway) - })) - defer srv.Close() - - l := routing.NewLogger(srv.URL, "test-token") - err := l.LogDecision(context.Background(), routing.LogEntry{Skill: "x", SessionID: "y", Decision: "local"}) - require.Error(t, err) -} diff --git a/internal/routing/passrate.go b/internal/routing/passrate.go deleted file mode 100644 index 97ae01d..0000000 --- a/internal/routing/passrate.go +++ /dev/null @@ -1,85 +0,0 @@ -package routing - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "sync" - "time" -) - -// Fetcher reads /pass-rate from the brain pod with a per-skill TTL cache. -type Fetcher struct { - BaseURL string - Window string - TTL time.Duration - HTTP *http.Client - - mu sync.Mutex - cache map[string]cachedRate -} - -type cachedRate struct { - value *float64 - at time.Time -} - -type passRateResponse struct { - PassRate *float64 `json:"pass_rate"` -} - -// NewFetcher returns a Fetcher that calls baseURL + /pass-rate with the -// given window string. If ttl is zero, defaults to 60 seconds. The HTTP -// client uses a 1-second total timeout. -func NewFetcher(baseURL, window string, ttl time.Duration) *Fetcher { - if ttl == 0 { - ttl = 60 * time.Second - } - return &Fetcher{ - BaseURL: baseURL, - Window: window, - TTL: ttl, - HTTP: &http.Client{Timeout: time.Second}, - cache: make(map[string]cachedRate), - } -} - -// Get returns the pass rate for the named skill, or nil if no data exists, -// or an error if the brain is unreachable. Caches successful results. -func (f *Fetcher) Get(ctx context.Context, skill string) (*float64, error) { - f.mu.Lock() - if c, ok := f.cache[skill]; ok && time.Since(c.at) < f.TTL { - v := c.value - f.mu.Unlock() - return v, nil - } - f.mu.Unlock() - - u := fmt.Sprintf("%s/pass-rate?skill=%s&window=%s", - f.BaseURL, url.QueryEscape(skill), url.QueryEscape(f.Window)) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, fmt.Errorf("passrate: build request: %w", err) - } - resp, err := f.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("passrate: request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("passrate: server returned status %d", resp.StatusCode) - } - - var body passRateResponse - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return nil, fmt.Errorf("passrate: decode: %w", err) - } - - f.mu.Lock() - f.cache[skill] = cachedRate{value: body.PassRate, at: time.Now()} - f.mu.Unlock() - - return body.PassRate, nil -} diff --git a/internal/routing/passrate_test.go b/internal/routing/passrate_test.go deleted file mode 100644 index f0b0f83..0000000 --- a/internal/routing/passrate_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package routing_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "github.com/mathiasbq/supervisor/internal/routing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFetcherGetReturnsPassRate(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "/pass-rate", r.URL.Path) - assert.Equal(t, "tdd", r.URL.Query().Get("skill")) - assert.Equal(t, "7d", r.URL.Query().Get("window")) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"skill": "tdd", "pass_rate": 0.94}) - })) - defer srv.Close() - - f := routing.NewFetcher(srv.URL, "7d", time.Minute) - pr, err := f.Get(context.Background(), "tdd") - require.NoError(t, err) - require.NotNil(t, pr) - assert.InDelta(t, 0.94, *pr, 1e-9) -} - -func TestFetcherGetReturnsNilWhenNoData(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{"skill": "novel", "pass_rate": nil}) - })) - defer srv.Close() - - f := routing.NewFetcher(srv.URL, "7d", time.Minute) - pr, err := f.Get(context.Background(), "novel") - require.NoError(t, err) - assert.Nil(t, pr) -} - -func TestFetcherCachesWithinTTL(t *testing.T) { - var calls int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - atomic.AddInt32(&calls, 1) - _ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.5}) - })) - defer srv.Close() - - f := routing.NewFetcher(srv.URL, "7d", time.Minute) - for i := 0; i < 5; i++ { - _, err := f.Get(context.Background(), "tdd") - require.NoError(t, err) - } - assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "should hit upstream once and serve four times from cache") -} - -func TestFetcherFetchesAgainAfterTTLExpires(t *testing.T) { - var calls int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - atomic.AddInt32(&calls, 1) - _ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.5}) - })) - defer srv.Close() - - // Tight TTL so the test stays fast. - f := routing.NewFetcher(srv.URL, "7d", 5*time.Millisecond) - _, err := f.Get(context.Background(), "tdd") - require.NoError(t, err) - assert.Equal(t, int32(1), atomic.LoadInt32(&calls)) - - // Sleep past TTL, then a second Get should hit upstream again. - time.Sleep(15 * time.Millisecond) - _, err = f.Get(context.Background(), "tdd") - require.NoError(t, err) - assert.Equal(t, int32(2), atomic.LoadInt32(&calls), "expected fresh upstream call after TTL expiry") -} - -func TestFetcherSurfacesUpstreamError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, "boom", http.StatusInternalServerError) - })) - defer srv.Close() - - f := routing.NewFetcher(srv.URL, "7d", time.Minute) - pr, err := f.Get(context.Background(), "tdd") - require.Error(t, err) - assert.Nil(t, pr) -} diff --git a/internal/routing/policy.go b/internal/routing/policy.go deleted file mode 100644 index 1ea09d1..0000000 --- a/internal/routing/policy.go +++ /dev/null @@ -1,47 +0,0 @@ -package routing - -// Decision is the route picked for a single skill call. -type Decision int - -const ( - DecideLocal Decision = iota - DecideClaude -) - -func (d Decision) String() string { - if d == DecideLocal { - return "local" - } - return "claude" -} - -// Policy holds the floor/ceil thresholds for routing decisions. -// -// Rules (in order): -// -// 1. passRate == nil → DecideLocal (default-to-local for cost-routable skills) -// 2. *passRate >= Floor → DecideLocal (trust local) -// 3. *passRate < Ceil → DecideClaude (don't trust local) -// 4. otherwise (sample band) → requestHash low bit picks: 0=local, 1=claude -type Policy struct { - Floor float64 - Ceil float64 -} - -// Decide returns the routing decision for a single call. -// requestHash is consulted only when passRate is in the sample band [Ceil, Floor). -func (p Policy) Decide(passRate *float64, requestHash uint64) Decision { - if passRate == nil { - return DecideLocal - } - if *passRate >= p.Floor { - return DecideLocal - } - if *passRate < p.Ceil { - return DecideClaude - } - if requestHash&1 == 0 { - return DecideLocal - } - return DecideClaude -} diff --git a/internal/routing/policy_test.go b/internal/routing/policy_test.go deleted file mode 100644 index 86d9841..0000000 --- a/internal/routing/policy_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package routing_test - -import ( - "testing" - - "github.com/mathiasbq/supervisor/internal/routing" - "github.com/stretchr/testify/assert" -) - -func ptr(f float64) *float64 { return &f } - -func TestPolicyDecide(t *testing.T) { - p := routing.Policy{Floor: 0.9, Ceil: 0.7} - - cases := []struct { - name string - passRate *float64 - hash uint64 - want routing.Decision - }{ - {"null pass rate → local", nil, 0, routing.DecideLocal}, - {"null pass rate, hash irrelevant → local", nil, 0xDEADBEEF, routing.DecideLocal}, - {"at floor → local", ptr(0.9), 0, routing.DecideLocal}, - {"above floor → local", ptr(0.95), 0, routing.DecideLocal}, - {"below ceil → claude", ptr(0.5), 0, routing.DecideClaude}, - {"at ceil → sample-band even-hash → local", ptr(0.7), 0, routing.DecideLocal}, - {"sample band, even hash → local", ptr(0.8), 2, routing.DecideLocal}, - {"sample band, odd hash → claude", ptr(0.8), 3, routing.DecideClaude}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, p.Decide(tc.passRate, tc.hash)) - }) - } -} diff --git a/internal/routing/router.go b/internal/routing/router.go deleted file mode 100644 index f42e838..0000000 --- a/internal/routing/router.go +++ /dev/null @@ -1,84 +0,0 @@ -package routing - -import ( - "context" - "fmt" - "log/slog" -) - -// CompleteFunc matches the signature used by every skill package's Config. -type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error) - -// RunInput captures the per-call inputs the dispatch wrapper needs. -type RunInput struct { - Skill string - System string - User string - SessionID string - ProjectRoot string -} - -// Router composes a pass-rate fetcher, a decision policy, a session logger, -// and a LiteLLM client. Skill packages receive Router.Run as their CompleteFunc. -type Router struct { - Fetcher *Fetcher - Logger *Logger - Policy Policy - FastModel string - ThinkingModel string - Complete CompleteFunc -} - -// Run executes one skill call: decides local vs claude, calls LiteLLM, logs the -// decision. On local-side error, falls open by retrying once on the Claude model. -func (r *Router) Run(ctx context.Context, in RunInput) (string, int64, error) { - pr, ferr := r.Fetcher.Get(ctx, in.Skill) - if ferr != nil { - slog.Warn("router: pass-rate unreachable, defaulting to local", "skill", in.Skill, "err", ferr) - pr = nil - } - hash := CanonicalHash(in.System, in.User) - decision := r.Policy.Decide(pr, hash) - - model := r.ThinkingModel - if decision == DecideLocal { - model = r.FastModel - } - - out, ms, err := r.Complete(ctx, model, in.System, in.User) - if lerr := r.Logger.LogDecision(ctx, LogEntry{ - SessionID: in.SessionID, - Skill: in.Skill, - Decision: decision.String(), - Message: fmt.Sprintf("model=%s, pass_rate=%s", model, formatPassRate(pr)), - ProjectRoot: in.ProjectRoot, - DurationMs: ms, - Failed: err != nil, - }); lerr != nil { - slog.Warn("router: log decision failed", "skill", in.Skill, "err", lerr) - } - - if err != nil && decision == DecideLocal { - slog.Warn("router: fast failed, falling open to thinking model", "skill", in.Skill, "err", err) - out, ms, err = r.Complete(ctx, r.ThinkingModel, in.System, in.User) - if lerr := r.Logger.LogDecision(ctx, LogEntry{ - SessionID: in.SessionID, - Skill: in.Skill, - Decision: "thinking_fallback", - Message: fmt.Sprintf("model=%s, after-fast-error", r.ThinkingModel), - ProjectRoot: in.ProjectRoot, - DurationMs: ms, - Failed: err != nil, - }); lerr != nil { - slog.Warn("router: log decision failed", "skill", in.Skill, "err", lerr) - } - } - return out, ms, err -} - -func formatPassRate(pr *float64) string { - if pr == nil { - return "null" - } - return fmt.Sprintf("%.2f", *pr) -} diff --git a/internal/routing/router_test.go b/internal/routing/router_test.go deleted file mode 100644 index a3aa174..0000000 --- a/internal/routing/router_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package routing_test - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" - - "github.com/mathiasbq/supervisor/internal/routing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type fakeLLM struct { - mu sync.Mutex - calls []struct{ Model, System, User string } - resp string - err error - errOn string // if non-empty, only the named model errors -} - -func (f *fakeLLM) Complete(_ context.Context, model, system, user string) (string, int64, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.calls = append(f.calls, struct{ Model, System, User string }{model, system, user}) - if f.errOn == model { - return "", 0, f.err - } - if f.err != nil && f.errOn == "" { - return "", 0, f.err - } - return f.resp, 100, nil -} - -func newRouter(t *testing.T, llm *fakeLLM, passRate float64) (*routing.Router, *httptest.Server, *httptest.Server) { - t.Helper() - brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/pass-rate": - _ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": passRate}) - case "/mcp": - _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}}) - } - })) - t.Cleanup(brain.Close) - - r := &routing.Router{ - Fetcher: routing.NewFetcher(brain.URL, "7d", time.Minute), - Logger: routing.NewLogger(brain.URL, ""), - Policy: routing.Policy{Floor: 0.9, Ceil: 0.7}, - FastModel: "koala/qwen35-9b-fast", - ThinkingModel: "iguana/gemma4-26b", - Complete: llm.Complete, - } - return r, brain, brain -} - -func TestRouterRoutesLocalAtHighPassRate(t *testing.T) { - llm := &fakeLLM{resp: "ok"} - r, _, _ := newRouter(t, llm, 0.95) - - out, _, err := r.Run(context.Background(), routing.RunInput{ - Skill: "review", System: "sys", User: "user", SessionID: "s1", ProjectRoot: "/p", - }) - require.NoError(t, err) - assert.Equal(t, "ok", out) - - llm.mu.Lock() - defer llm.mu.Unlock() - require.Len(t, llm.calls, 1) - assert.Equal(t, "koala/qwen35-9b-fast", llm.calls[0].Model) -} - -func TestRouterRoutesThinkingAtLowPassRate(t *testing.T) { - llm := &fakeLLM{resp: "ok"} - r, _, _ := newRouter(t, llm, 0.3) - - _, _, err := r.Run(context.Background(), routing.RunInput{ - Skill: "review", System: "sys", User: "user", SessionID: "s2", - }) - require.NoError(t, err) - - llm.mu.Lock() - defer llm.mu.Unlock() - require.Len(t, llm.calls, 1) - assert.Equal(t, "iguana/gemma4-26b", llm.calls[0].Model) -} - -func TestRouterFailsOpenFastErrorToThinking(t *testing.T) { - llm := &fakeLLM{resp: "ok-after-fallback", err: errors.New("fast boom"), errOn: "koala/qwen35-9b-fast"} - r, _, _ := newRouter(t, llm, 0.95) // would route fast - - out, _, err := r.Run(context.Background(), routing.RunInput{ - Skill: "review", System: "sys", User: "user", SessionID: "s3", - }) - require.NoError(t, err) - assert.Equal(t, "ok-after-fallback", out) - - llm.mu.Lock() - defer llm.mu.Unlock() - require.Len(t, llm.calls, 2) - assert.Equal(t, "koala/qwen35-9b-fast", llm.calls[0].Model) - assert.Equal(t, "iguana/gemma4-26b", llm.calls[1].Model) -} - -func TestRouterDefaultsToFastWhenBrainUnreachable(t *testing.T) { - // Brain returns 500 → fetcher errors → router treats pass rate as nil → fast. - brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, "down", http.StatusInternalServerError) - })) - defer brain.Close() - - llm := &fakeLLM{resp: "ok"} - r := &routing.Router{ - Fetcher: routing.NewFetcher(brain.URL, "7d", time.Minute), - Logger: routing.NewLogger(brain.URL, ""), - Policy: routing.Policy{Floor: 0.9, Ceil: 0.7}, - FastModel: "koala/qwen35-9b-fast", - ThinkingModel: "iguana/gemma4-26b", - Complete: llm.Complete, - } - - _, _, err := r.Run(context.Background(), routing.RunInput{ - Skill: "review", System: "sys", User: "user", SessionID: "s4", - }) - require.NoError(t, err) - - llm.mu.Lock() - defer llm.mu.Unlock() - require.Len(t, llm.calls, 1) - assert.Equal(t, "koala/qwen35-9b-fast", llm.calls[0].Model) -} diff --git a/internal/routing/snapshot_test.go b/internal/routing/snapshot_test.go deleted file mode 100644 index c79f7df..0000000 --- a/internal/routing/snapshot_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package routing_test - -import ( - "context" - "encoding/json" - "os" - "sort" - "testing" - - "github.com/mathiasbq/supervisor/internal/registry" - "github.com/mathiasbq/supervisor/internal/skills/debug" - "github.com/mathiasbq/supervisor/internal/skills/retrospective" - "github.com/mathiasbq/supervisor/internal/skills/review" - "github.com/mathiasbq/supervisor/internal/skills/trainer" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestToolsListMatchesSupervisorSnapshot pins the four routed skills' tool -// definitions to the supervisor's current advertisement. A deliberate schema -// change must be reflected here by updating testdata/tools_list.snapshot.json. -func TestToolsListMatchesSupervisorSnapshot(t *testing.T) { - complete := func(_ context.Context, _, _, _ string) (string, int64, error) { - return "", 0, nil - } - - reg := registry.New() - reg.Register(review.New(review.Config{ - SkillPrompt: "stub", - DefaultModel: "stub", - CompleteFunc: complete, - })) - reg.Register(debug.New(debug.Config{ - SkillPrompt: "stub", - DefaultModel: "stub", - CompleteFunc: complete, - })) - reg.Register(retrospective.New(retrospective.Config{ - SkillPrompt: "stub", - DefaultModel: "stub", - CompleteFunc: complete, - })) - reg.Register(trainer.New(trainer.Config{ - ReaderPrompt: "stub", - WriterPrompt: "stub", - DefaultModel: "stub", - CompleteFunc: complete, - })) - - wanted := map[string]bool{ - "review": true, - "debug": true, - "retrospective": true, - "trainer": true, - } - var routed []registry.ToolDef - for _, td := range reg.Tools() { - if wanted[td.Name] { - routed = append(routed, td) - } - } - sort.Slice(routed, func(i, j int) bool { return routed[i].Name < routed[j].Name }) - - got, err := json.MarshalIndent(routed, "", " ") - require.NoError(t, err) - - want, err := os.ReadFile("testdata/tools_list.snapshot.json") - require.NoError(t, err) - - // Normalize both via re-encode so whitespace differences don't dominate. - var gotV, wantV any - require.NoError(t, json.Unmarshal(got, &gotV)) - require.NoError(t, json.Unmarshal(want, &wantV)) - - gotN, _ := json.MarshalIndent(gotV, "", " ") - wantN, _ := json.MarshalIndent(wantV, "", " ") - - assert.Equal(t, string(wantN), string(gotN), - "tool advertisement drifted from supervisor snapshot — update testdata/tools_list.snapshot.json deliberately if the schema change is intentional") -} diff --git a/internal/routing/testdata/tools_list.snapshot.json b/internal/routing/testdata/tools_list.snapshot.json deleted file mode 100644 index 859602e..0000000 --- a/internal/routing/testdata/tools_list.snapshot.json +++ /dev/null @@ -1,97 +0,0 @@ -[ - { - "name": "debug", - "description": "Consult a local model to analyse an error and return hypotheses ordered by likelihood, each with a concrete verification step.", - "inputSchema": { - "properties": { - "context": { - "type": "string" - }, - "error": { - "type": "string" - }, - "model": { - "type": "string" - }, - "project_root": { - "type": "string" - }, - "session_id": { - "type": "string" - } - }, - "required": [ - "project_root", - "error" - ], - "type": "object" - } - }, - { - "name": "retrospective", - "description": "Consult a local model to analyse a completed session and identify what is novel or worth preserving as organizational knowledge.", - "inputSchema": { - "type": "object", - "required": [ - "session_id" - ], - "properties": { - "session_id": { - "type": "string" - }, - "model": { - "type": "string" - } - } - } - }, - { - "name": "review", - "description": "Consult a local model for a structured code review of the specified files. Returns findings with severity levels.", - "inputSchema": { - "properties": { - "context": { - "type": "string" - }, - "files": { - "items": { - "type": "string" - }, - "type": "array" - }, - "model": { - "type": "string" - }, - "project_root": { - "type": "string" - }, - "session_id": { - "type": "string" - } - }, - "required": [ - "project_root", - "files" - ], - "type": "object" - } - }, - { - "name": "trainer", - "description": "Consult a local model to identify learning moments from a session log and suggest knowledge to preserve in the brain.", - "inputSchema": { - "properties": { - "model": { - "type": "string" - }, - "session_id": { - "type": "string" - } - }, - "required": [ - "session_id" - ], - "type": "object" - } - } -] diff --git a/internal/skills/debug/handlers.go b/internal/skills/debug/handlers.go deleted file mode 100644 index 6ebf89c..0000000 --- a/internal/skills/debug/handlers.go +++ /dev/null @@ -1,82 +0,0 @@ -// internal/skills/debug/handlers.go -package debug - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/mathiasbq/supervisor/internal/brain" - "github.com/mathiasbq/supervisor/internal/session" -) - -type debugArgs struct { - ProjectRoot string `json:"project_root"` - Error string `json:"error"` - Context string `json:"context"` - Model string `json:"model"` - SessionID string `json:"session_id"` -} - -// Handle dispatches the MCP tool call to the appropriate handler. -func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { - if tool != "debug" { - return nil, fmt.Errorf("unknown tool: %s", tool) - } - var a debugArgs - if err := json.Unmarshal(args, &a); err != nil { - return nil, fmt.Errorf("parse args: %w", err) - } - if a.ProjectRoot == "" { - return nil, fmt.Errorf("project_root is required") - } - if a.Error == "" { - return nil, fmt.Errorf("error is required") - } - - model := a.Model - if model == "" { - model = s.cfg.DefaultModel - } - - brainCtx, _ := brain.Query(ctx, s.cfg.IngestBaseURL, a.Error+" "+a.Context, 3) - - task := fmt.Sprintf( - "phase: debug\nproject_root: %s\nerror: %s\ncontext: %s\nmodel: %s", - a.ProjectRoot, a.Error, a.Context, model, - ) - task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "debug", task) - if brainCtx != "" { - task = brainCtx + "\n---\n\n" + task - } - - if s.cfg.CompleteFunc == nil { - return nil, fmt.Errorf("no executor configured") - } - t0 := time.Now() - text, dur, err := s.cfg.CompleteFunc(ctx, model, s.cfg.SkillPrompt, task) - if err != nil { - return nil, err - } - - if a.SessionID != "" && s.cfg.SessionsDir != "" { - msg := text - if len(msg) > 200 { - msg = msg[:200] - } - _ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{ - SessionID: a.SessionID, - Timestamp: time.Now(), - Skill: "debug", - Phase: "debug", - ProjectRoot: a.ProjectRoot, - FinalStatus: "ok", - ModelUsed: model, - DurationMs: time.Since(t0).Milliseconds(), - Message: msg, - }) - } - - return json.Marshal(map[string]any{"text": text, "model": model, "duration_ms": dur}) -} diff --git a/internal/skills/debug/handlers_test.go b/internal/skills/debug/handlers_test.go deleted file mode 100644 index f7c4ebb..0000000 --- a/internal/skills/debug/handlers_test.go +++ /dev/null @@ -1,53 +0,0 @@ -// internal/skills/debug/handlers_test.go -package debug_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/mathiasbq/supervisor/internal/skills/debug" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDebugToolRegistered(t *testing.T) { - sk := debug.New(debug.Config{SkillPrompt: "debug rules"}) - names := make([]string, 0) - for _, tool := range sk.Tools() { - names = append(names, tool.Name) - } - assert.Contains(t, names, "debug") -} - -func TestDebugRequiresProjectRoot(t *testing.T) { - sk := debug.New(debug.Config{SkillPrompt: "d"}) - _, err := sk.Handle(context.Background(), "debug", json.RawMessage(`{"error":"panic: nil pointer"}`)) - assert.ErrorContains(t, err, "project_root") -} - -func TestDebugRequiresError(t *testing.T) { - sk := debug.New(debug.Config{SkillPrompt: "d"}) - _, err := sk.Handle(context.Background(), "debug", json.RawMessage(`{"project_root":"/tmp"}`)) - assert.ErrorContains(t, err, "error") -} - -func TestDebugCallsCompleteFunc(t *testing.T) { - var capturedTask string - fakeFn := func(_ context.Context, _, _, user string) (string, int64, error) { - capturedTask = user - return "HYPOTHESIS 1 (high): nil map access. Verify: go test ./...", 90, nil - } - - sk := debug.New(debug.Config{SkillPrompt: "debug rules", CompleteFunc: fakeFn, SessionsDir: t.TempDir()}) - out, err := sk.Handle(context.Background(), "debug", json.RawMessage( - `{"project_root":"/tmp/proj","error":"panic: nil pointer dereference at foo.go:42","context":"occurs on startup"}`, - )) - require.NoError(t, err) - assert.Contains(t, capturedTask, "panic: nil pointer dereference") - assert.Contains(t, capturedTask, "occurs on startup") - - var result map[string]any - require.NoError(t, json.Unmarshal(out, &result)) - assert.Contains(t, result["text"], "nil map access") -} diff --git a/internal/skills/debug/skill.go b/internal/skills/debug/skill.go deleted file mode 100644 index 8a97ccf..0000000 --- a/internal/skills/debug/skill.go +++ /dev/null @@ -1,55 +0,0 @@ -// internal/skills/debug/skill.go -package debug - -import ( - "context" - "encoding/json" - - "github.com/mathiasbq/supervisor/internal/registry" -) - -// CompleteFunc is the function used to call a local model. -type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error) - -// Config holds dependencies for the debug skill. -type Config struct { - SkillPrompt string - DefaultModel string - CompleteFunc CompleteFunc - SessionsDir string - IngestBaseURL string -} - -// Skill implements the debug MCP tool. -type Skill struct{ cfg Config } - -// New creates a new debug Skill. -func New(cfg Config) *Skill { return &Skill{cfg: cfg} } - -// Name returns the skill identifier. -func (s *Skill) Name() string { return "debug" } - -// Tools returns the MCP tool definitions for this skill. -func (s *Skill) Tools() []registry.ToolDef { - schema := func(required []string, props map[string]any) json.RawMessage { - b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props}) - return b - } - str := map[string]any{"type": "string"} - return []registry.ToolDef{ - { - Name: "debug", - Description: "Consult a local model to analyse an error and return hypotheses ordered by likelihood, each with a concrete verification step.", - InputSchema: schema( - []string{"project_root", "error"}, - map[string]any{ - "project_root": str, - "error": str, - "context": str, - "model": str, - "session_id": str, - }, - ), - }, - } -} diff --git a/internal/skills/project/handlers.go b/internal/skills/project/handlers.go deleted file mode 100644 index a5d41e5..0000000 --- a/internal/skills/project/handlers.go +++ /dev/null @@ -1,297 +0,0 @@ -package project - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/mathiasbq/supervisor/internal/githubclient" - "github.com/mathiasbq/supervisor/internal/mcpclient" -) - -type createArgs struct { - Name string `json:"name"` - Description string `json:"description"` - Hypothesis string `json:"hypothesis"` - Folder string `json:"folder"` - Stack string `json:"stack"` - Private bool `json:"private"` - MirrorToGitHub bool `json:"mirror_to_github,omitempty"` -} - -type createResult struct { - GiteaURL string `json:"gitea_url"` - GitHubURL string `json:"github_url"` - IssueURL string `json:"issue_url"` - NextSteps string `json:"next_steps"` - - // Reached records the steps that completed. Populated on partial failure - // so callers can resume manually instead of guessing what already ran. - Reached []string `json:"reached,omitempty"` - - // FailedStep is non-empty when a downstream gitea-mcp call returned an - // error; the error itself is surfaced via the JSON-RPC error response, - // this field tells the operator which step it happened in. - FailedStep string `json:"failed_step,omitempty"` -} - -func errUnknownTool(name string) error { return fmt.Errorf("unknown tool: %s", name) } - -// step names — must match what we surface in failed_step / reached. -const ( - stepCreateRepo = "create_repo" - stepCreateGitHub = "create_github_repo" - stepMirror = "mirror" - stepInfraCommit = "infra_commit" - stepIssue = "issue" -) - -func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { - var args createArgs - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("parse args: %w", err) - } - if err := validate(args); err != nil { - return nil, err - } - - tmpl := templateFor(args.Stack) - giteaURL := fmt.Sprintf("http://gitea.d-ma.be/%s/%s", s.cfg.GiteaOwner, args.Name) - - res := createResult{ - GiteaURL: giteaURL, - } - if args.MirrorToGitHub { - res.GitHubURL = fmt.Sprintf("https://github.com/%s/%s", s.cfg.GitHubOwner, args.Name) - } - - // Step 1: create_project_from_template. If the repo already exists, - // gitea-mcp returns -32003 Conflict; we treat that as idempotent success - // and continue to the next steps so re-running self-heals partial runs. - existed, err := s.callCreateRepo(ctx, args, tmpl) - if err != nil { - return marshalPartial(res, stepCreateRepo, err) - } - res.Reached = append(res.Reached, stepCreateRepo) - - // Steps 2+3 are skipped when MirrorToGitHub is false. Default per - // infra ADR (Gitea as true master, GitHub as optional opt-in): keep - // client / business-logic / personal repos Gitea-only. Set - // `mirror_to_github: true` for open-source projects that want a - // public GitHub mirror (hyperguild, gitea-mcp, template-*). - if args.MirrorToGitHub { - // Step 2: create empty GitHub repo. Gitea's push-mirror cannot push - // to a non-existent remote, so the destination must exist before - // step 3 configures the mirror. Skipped when GitHub client is unset - // (degraded mode — see Config.GitHub doc). - if s.cfg.GitHub != nil { - if err := s.callCreateGitHubRepo(ctx, args); err != nil && !errors.Is(err, githubclient.ErrAlreadyExists) { - return marshalPartial(res, stepCreateGitHub, err) - } - res.Reached = append(res.Reached, stepCreateGitHub) - } - - // Step 3: configure push mirror to GitHub. Idempotent: if a mirror with - // the same remote already exists, gitea-mcp returns Conflict; we swallow it. - if err := s.callMirror(ctx, args.Name); err != nil { - if !isConflict(err) { - return marshalPartial(res, stepMirror, err) - } - } - res.Reached = append(res.Reached, stepMirror) - } - - // Step 3: commit staging namespace manifest to infra repo. Done before - // the issue so the staging env is reconciling by the time the issue lands. - if err := s.callInfraCommit(ctx, args.Name); err != nil { - if !isConflict(err) { - return marshalPartial(res, stepInfraCommit, err) - } - } - res.Reached = append(res.Reached, stepInfraCommit) - - // Step 4: open the experiment-brief issue on the new repo. - issueURL, err := s.callIssue(ctx, args, existed) - if err != nil { - return marshalPartial(res, stepIssue, err) - } - res.IssueURL = issueURL - res.Reached = append(res.Reached, stepIssue) - - folder := args.Folder - if folder == "" { - folder = "." - } - res.NextSteps = fmt.Sprintf( - "cd ~/dev/%s/%s && task new-project -- %s personal %s %s && git remote add origin http://gitea.d-ma.be/%s/%s.git && git push -u origin main", - folder, args.Name, args.Name, folder, args.Stack, s.cfg.GiteaOwner, args.Name, - ) - - return marshalResult(res) -} - -// callCreateRepo invokes create_project_from_template. Returns (existed, err) -// where existed=true means the destination was already present and we should -// treat it as a no-op success (idempotency). -func (s *Skill) callCreateRepo(ctx context.Context, args createArgs, template string) (bool, error) { - var out struct { - HTMLURL string `json:"html_url"` - } - err := s.cfg.Client.CallTool(ctx, "create_project_from_template", map[string]any{ - "owner": s.cfg.GiteaOwner, - "name": args.Name, - "description": args.Description, - "private": args.Private, - "template_name": template, - }, &out) - if err == nil { - return false, nil - } - if isConflict(err) { - return true, nil - } - return false, err -} - -// callCreateGitHubRepo creates the empty destination repo on GitHub. -// auto_init=false in githubclient so first push from gitea doesn't conflict -// with an auto-generated README. -func (s *Skill) callCreateGitHubRepo(ctx context.Context, args createArgs) error { - _, err := s.cfg.GitHub.CreateRepo(ctx, args.Name, args.Description, args.Private) - return err -} - -// callMirror configures the push mirror to GitHub. -func (s *Skill) callMirror(ctx context.Context, name string) error { - remote := fmt.Sprintf("https://github.com/%s/%s.git", s.cfg.GitHubOwner, name) - return s.cfg.Client.CallTool(ctx, "repo_mirror_push", map[string]any{ - "owner": s.cfg.GiteaOwner, - "name": name, - "action": "add", - "remote_address": remote, - "remote_username": s.cfg.GitHubOwner, - "remote_password": s.cfg.GitHubPAT, - "interval": "8h0m0s", - "sync_on_commit": true, - }, nil) -} - -// callInfraCommit writes the staging namespace manifest directly to infra -// main. Flux reconciles within ~60s. See DECISIONS.md 2026-05-18. -func (s *Skill) callInfraCommit(ctx context.Context, name string) error { - manifest := stagingNamespaceManifest(name, time.Now().UTC().Format(time.RFC3339)) - return s.cfg.Client.CallTool(ctx, "file_write_branch", map[string]any{ - "owner": s.cfg.GiteaOwner, - "name": s.cfg.InfraRepo, - "path": fmt.Sprintf("k3s/staging/%s/namespace.yaml", name), - "content": manifest, - "branch": "main", - "message": fmt.Sprintf("feat(staging): add namespace for %s\n\nGenerated by hyperguild project_create.", name), - }, nil) -} - -// callIssue opens the experiment-brief issue on the newly-created repo. -// existed=true (repo pre-existed) still posts a new brief — repeated runs -// can intentionally restate intent without colliding. -func (s *Skill) callIssue(ctx context.Context, args createArgs, existed bool) (string, error) { - body := experimentBrief(args, existed) - var out struct { - HTMLURL string `json:"html_url"` - } - err := s.cfg.Client.CallTool(ctx, "issue_create", map[string]any{ - "owner": s.cfg.GiteaOwner, - "name": args.Name, - "title": "experiment brief: " + args.Description, - "body": body, - }, &out) - if err != nil { - return "", err - } - return out.HTMLURL, nil -} - -func stagingNamespaceManifest(name, createdAt string) string { - return fmt.Sprintf(`apiVersion: v1 -kind: Namespace -metadata: - name: staging-%s - labels: - managed-by: hyperguild - project: %s - created-at: "%s" -`, name, name, createdAt) -} - -func experimentBrief(args createArgs, existed bool) string { - var b strings.Builder - b.WriteString("## Hypothesis\n\n") - b.WriteString(args.Hypothesis) - b.WriteString("\n\n## Description\n\n") - b.WriteString(args.Description) - b.WriteString("\n\n## Stack\n\n`") - b.WriteString(args.Stack) - b.WriteString("`\n\n## Provisioning\n\n") - b.WriteString("- Repo created from `template-") - b.WriteString(args.Stack) - b.WriteString("` on Gitea.\n") - if args.MirrorToGitHub { - b.WriteString("- Push-mirror configured to GitHub.\n") - } else { - b.WriteString("- Gitea-only (no GitHub mirror — set `mirror_to_github: true` to opt in).\n") - } - b.WriteString("- Staging namespace manifest committed to infra repo.\n\n") - if existed { - b.WriteString("> Note: this repo already existed when `project_create` ran — provisioning steps were re-applied idempotently.\n") - } - return b.String() -} - -func validate(args createArgs) error { - if args.Name == "" { - return errors.New("name is required") - } - if args.Description == "" { - return errors.New("description is required") - } - if args.Hypothesis == "" { - return errors.New("hypothesis is required") - } - if args.Stack != "go-agent" && args.Stack != "go-web" { - return fmt.Errorf("stack must be go-agent or go-web, got %q", args.Stack) - } - return nil -} - -func templateFor(stack string) string { - switch stack { - case "go-agent": - return "template-go-agent" - default: - return "template-go-web" - } -} - -func isConflict(err error) bool { - var me *mcpclient.Error - if errors.As(err, &me) && me.Code == -32003 { - return true - } - return false -} - -func marshalResult(r createResult) (json.RawMessage, error) { - b, err := json.Marshal(r) - if err != nil { - return nil, fmt.Errorf("marshal result: %w", err) - } - return b, nil -} - -func marshalPartial(r createResult, step string, inner error) (json.RawMessage, error) { - r.FailedStep = step - b, _ := json.Marshal(r) - return b, fmt.Errorf("project_create step %q failed: %w", step, inner) -} diff --git a/internal/skills/project/handlers_test.go b/internal/skills/project/handlers_test.go deleted file mode 100644 index fa934e8..0000000 --- a/internal/skills/project/handlers_test.go +++ /dev/null @@ -1,419 +0,0 @@ -package project_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - - "github.com/mathiasbq/supervisor/internal/githubclient" - "github.com/mathiasbq/supervisor/internal/mcpclient" - "github.com/mathiasbq/supervisor/internal/skills/project" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// fakeGitHub captures POST /user/repos calls. -type fakeGitHub struct { - mu sync.Mutex - Calls []map[string]any - ReturnError int // 0 = 201 Created, 422 = already exists, etc. -} - -func (g *fakeGitHub) handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var args map[string]any - _ = json.NewDecoder(r.Body).Decode(&args) - g.mu.Lock() - g.Calls = append(g.Calls, args) - code := g.ReturnError - g.mu.Unlock() - switch code { - case 0: - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"full_name":"mathiasb/x","html_url":"https://github.com/mathiasb/x","clone_url":"https://github.com/mathiasb/x.git"}`)) - case 422: - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"errors":[{"message":"name already exists on this account"}]}`)) - default: - w.WriteHeader(code) - _, _ = w.Write([]byte(`{"message":"boom"}`)) - } - }) -} - -// fakeGiteaMCP implements just enough of the JSON-RPC tools/call surface -// to drive project_create end-to-end without an actual gitea-mcp server. -type fakeGiteaMCP struct { - mu sync.Mutex - // Recorded calls in order. - Calls []recordedCall - // Per-tool response. Default is a generic success object. - Responses map[string]any - // Per-tool error response, takes precedence over Responses. - Errors map[string]rpcErr -} - -type rpcErr struct { - Code int - Message string -} - -type recordedCall struct { - Tool string - Args map[string]any -} - -func (f *fakeGiteaMCP) handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req struct { - ID int `json:"id"` - Params json.RawMessage `json:"params"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - var p struct { - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` - } - _ = json.Unmarshal(req.Params, &p) - var args map[string]any - _ = json.Unmarshal(p.Arguments, &args) - - f.mu.Lock() - f.Calls = append(f.Calls, recordedCall{Tool: p.Name, Args: args}) - errResp, hasErr := f.Errors[p.Name] - var resp any - if r, ok := f.Responses[p.Name]; ok { - resp = r - } else { - resp = map[string]any{"html_url": "http://gitea.example/" + p.Name} - } - f.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - if hasErr { - body, _ := json.Marshal(map[string]any{ - "jsonrpc": "2.0", - "id": req.ID, - "error": map[string]any{"code": errResp.Code, "message": errResp.Message}, - }) - _, _ = w.Write(body) - return - } - respText, _ := json.Marshal(resp) - body, _ := json.Marshal(map[string]any{ - "jsonrpc": "2.0", - "id": req.ID, - "result": map[string]any{ - "content": []map[string]any{{"type": "text", "text": string(respText)}}, - }, - }) - _, _ = w.Write(body) - }) -} - -func newSkill(t *testing.T, f *fakeGiteaMCP) (*project.Skill, *fakeGitHub) { - t.Helper() - srv := httptest.NewServer(f.handler()) - t.Cleanup(srv.Close) - - gh := &fakeGitHub{} - ghSrv := httptest.NewServer(gh.handler()) - t.Cleanup(ghSrv.Close) - - return project.New(project.Config{ - Client: mustClient(t, srv.URL), - GitHub: githubclient.New("ghp_test").WithBaseURL(ghSrv.URL), - GiteaOwner: "mathias", - GitHubOwner: "mathiasb", - GitHubPAT: "ghp_test", - InfraRepo: "infra", - }), gh -} - -// newSkillNoGitHub builds a skill with the GitHub client unset — degraded -// mode where the github-repo-creation step is skipped. -func newSkillNoGitHub(t *testing.T, f *fakeGiteaMCP) *project.Skill { - t.Helper() - srv := httptest.NewServer(f.handler()) - t.Cleanup(srv.Close) - return project.New(project.Config{ - Client: mustClient(t, srv.URL), - GiteaOwner: "mathias", - GitHubOwner: "mathiasb", - InfraRepo: "infra", - }) -} - -// mustClient builds an mcpclient against an httptest server. Uses a -// non-empty dummy token because httptest servers don't enforce bearer -// auth, but mcpclient.New now requires non-empty token (see #13). -func mustClient(t *testing.T, url string) *mcpclient.Client { - t.Helper() - c, err := mcpclient.New(url, "test-token") - require.NoError(t, err) - return c -} - -// happyArgs returns the minimal valid request. With the Gitea-as-true-master -// ADR shipped, this defaults to Gitea-only (mirror_to_github omitted = false). -// Tests that need the full Gitea + GitHub mirror flow use mirroredArgs(). -func happyArgs() json.RawMessage { - return json.RawMessage(`{ - "name":"my-experiment", - "description":"One-line desc", - "hypothesis":"We believe X produces Y", - "folder":"AGENTS", - "stack":"go-agent", - "private":true - }`) -} - -// mirroredArgs is happyArgs + mirror_to_github=true — the explicit opt-in -// path. Equivalent to the pre-ADR default. -func mirroredArgs() json.RawMessage { - return json.RawMessage(`{ - "name":"my-experiment", - "description":"One-line desc", - "hypothesis":"We believe X produces Y", - "folder":"AGENTS", - "stack":"go-agent", - "private":true, - "mirror_to_github":true - }`) -} - -func TestProjectCreate_HappyPath(t *testing.T) { - f := &fakeGiteaMCP{ - Responses: map[string]any{ - "issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"}, - }, - } - skill, gh := newSkill(t, f) - - out, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) - require.NoError(t, err) - - var res map[string]any - require.NoError(t, json.Unmarshal(out, &res)) - assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"]) - assert.Equal(t, "https://github.com/mathiasb/my-experiment", res["github_url"]) - assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"]) - assert.Contains(t, res["next_steps"], "cd ~/dev/AGENTS/my-experiment") - assert.Contains(t, res["next_steps"], "git remote add origin") - - // All 4 gitea-mcp calls in order. - require.Len(t, f.Calls, 4) - assert.Equal(t, "create_project_from_template", f.Calls[0].Tool) - assert.Equal(t, "repo_mirror_push", f.Calls[1].Tool) - assert.Equal(t, "file_write_branch", f.Calls[2].Tool) - assert.Equal(t, "issue_create", f.Calls[3].Tool) - - // GitHub repo created between create_project_from_template and mirror. - require.Len(t, gh.Calls, 1) - assert.Equal(t, "my-experiment", gh.Calls[0]["name"]) - assert.Equal(t, true, gh.Calls[0]["private"]) - assert.Equal(t, false, gh.Calls[0]["auto_init"]) - - // template selection wired from stack - assert.Equal(t, "template-go-agent", f.Calls[0].Args["template_name"]) - // mirror config - assert.Equal(t, "add", f.Calls[1].Args["action"]) - assert.Equal(t, "https://github.com/mathiasb/my-experiment.git", f.Calls[1].Args["remote_address"]) - assert.Equal(t, "ghp_test", f.Calls[1].Args["remote_password"]) - // infra commit path - assert.Equal(t, "k3s/staging/my-experiment/namespace.yaml", f.Calls[2].Args["path"]) - assert.Contains(t, f.Calls[2].Args["content"], "name: staging-my-experiment") - assert.Contains(t, f.Calls[2].Args["content"], "managed-by: hyperguild") - // PAT must NOT appear in the response - assert.NotContains(t, string(out), "ghp_test") - - // reached records the github step too. - reached := res["reached"].([]any) - assert.Equal(t, []any{"create_repo", "create_github_repo", "mirror", "infra_commit", "issue"}, reached) -} - -func TestProjectCreate_GitHubExists_Idempotent(t *testing.T) { - f := &fakeGiteaMCP{ - Responses: map[string]any{ - "issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"}, - }, - } - skill, gh := newSkill(t, f) - gh.ReturnError = 422 // already exists - - _, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) - require.NoError(t, err, "422 already-exists should be idempotent") - require.Len(t, f.Calls, 4, "all gitea steps still run despite github 422") -} - -func TestProjectCreate_GitHubFails(t *testing.T) { - f := &fakeGiteaMCP{} - skill, gh := newSkill(t, f) - gh.ReturnError = 401 // bad PAT - - out, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) - require.Error(t, err) - var res map[string]any - require.NoError(t, json.Unmarshal(out, &res)) - assert.Equal(t, "create_github_repo", res["failed_step"]) - assert.Equal(t, []any{"create_repo"}, res["reached"]) - require.Len(t, f.Calls, 1, "mirror + later steps must not run when github creation fails") -} - -func TestProjectCreate_NoGitHubClient_DegradedMode(t *testing.T) { - f := &fakeGiteaMCP{ - Responses: map[string]any{ - "issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"}, - }, - } - skill := newSkillNoGitHub(t, f) - - // Use mirroredArgs so we exercise the GitHub-mirror path. With the - // GitHub client nil, the create_github_repo step is skipped but the - // mirror step still attempts to configure the push-mirror remote - // (degraded mode preserves the prior contract for opted-in projects). - out, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) - require.NoError(t, err) - var res map[string]any - require.NoError(t, json.Unmarshal(out, &res)) - // reached does NOT include create_github_repo when client is nil. - reached := res["reached"].([]any) - assert.Equal(t, []any{"create_repo", "mirror", "infra_commit", "issue"}, reached) -} - -func TestProjectCreate_Idempotent_RepoExists(t *testing.T) { - f := &fakeGiteaMCP{ - Errors: map[string]rpcErr{ - "create_project_from_template": {Code: -32003, Message: "already exists"}, - }, - Responses: map[string]any{ - "issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"}, - }, - } - skill, _ := newSkill(t, f) - - out, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) - require.NoError(t, err) - - var res map[string]any - require.NoError(t, json.Unmarshal(out, &res)) - assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"]) - assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"]) - - // Still ran all 4 gitea-mcp steps; idempotent flow falls through. - require.Len(t, f.Calls, 4) -} - -func TestProjectCreate_MirrorFails(t *testing.T) { - f := &fakeGiteaMCP{ - Errors: map[string]rpcErr{ - "repo_mirror_push": {Code: -32000, Message: "github unreachable"}, - }, - } - skill, _ := newSkill(t, f) - - out, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) - require.Error(t, err) - assert.Contains(t, err.Error(), `"mirror" failed`) - - var res map[string]any - require.NoError(t, json.Unmarshal(out, &res)) - assert.Equal(t, "mirror", res["failed_step"]) - reached := res["reached"].([]any) - assert.Equal(t, []any{"create_repo", "create_github_repo"}, reached) - - // Steps 1 (create) + 2 (mirror attempt) reached gitea; github made 1 call. - require.Len(t, f.Calls, 2) -} - -func TestProjectCreate_InfraCommitFails(t *testing.T) { - f := &fakeGiteaMCP{ - Errors: map[string]rpcErr{ - "file_write_branch": {Code: -32000, Message: "write rejected"}, - }, - } - skill, _ := newSkill(t, f) - - out, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) - require.Error(t, err) - - var res map[string]any - require.NoError(t, json.Unmarshal(out, &res)) - assert.Equal(t, "infra_commit", res["failed_step"]) - reached := res["reached"].([]any) - assert.Equal(t, []any{"create_repo", "create_github_repo", "mirror"}, reached) - require.Len(t, f.Calls, 3) -} - -func TestProjectCreate_ValidationErrors(t *testing.T) { - f := &fakeGiteaMCP{} - skill, _ := newSkill(t, f) - cases := []struct { - name string - body string - want string - }{ - {"missing name", `{"description":"d","hypothesis":"h","stack":"go-agent"}`, "name"}, - {"missing description", `{"name":"x","hypothesis":"h","stack":"go-agent"}`, "description"}, - {"missing hypothesis", `{"name":"x","description":"d","stack":"go-agent"}`, "hypothesis"}, - {"bad stack", `{"name":"x","description":"d","hypothesis":"h","stack":"python"}`, "stack"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, err := skill.Handle(context.Background(), "project_create", json.RawMessage(tc.body)) - require.Error(t, err) - assert.True(t, strings.Contains(err.Error(), tc.want), "want %q in %v", tc.want, err) - }) - } - assert.Empty(t, f.Calls, "no upstream calls should occur on validation failure") -} - -func TestProjectCreate_DefaultSkipsGitHubMirror(t *testing.T) { - // Default (mirror_to_github omitted) skips create_github_repo + mirror - // per the Gitea-as-true-master ADR. Gitea repo + staging namespace - // + issue still run; github_url is empty in the response. - f := &fakeGiteaMCP{ - Responses: map[string]any{ - "issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"}, - }, - } - skill, gh := newSkill(t, f) - - out, err := skill.Handle(context.Background(), "project_create", happyArgs()) - require.NoError(t, err) - - var res map[string]any - require.NoError(t, json.Unmarshal(out, &res)) - - assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"]) - assert.Equal(t, "", res["github_url"], "github_url must be empty when mirror not opted in") - assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"]) - - // 3 gitea-mcp calls: template create, staging file write, issue. NO mirror call. - require.Len(t, f.Calls, 3) - assert.Equal(t, "create_project_from_template", f.Calls[0].Tool) - assert.Equal(t, "file_write_branch", f.Calls[1].Tool) - assert.Equal(t, "issue_create", f.Calls[2].Tool) - - // Zero GitHub API calls. - assert.Empty(t, gh.Calls, "no GitHub repo created when mirror_to_github is false") - - // reached lists the Gitea-only path. - reached := res["reached"].([]any) - assert.Equal(t, []any{"create_repo", "infra_commit", "issue"}, reached) - - // experiment-brief body reflects Gitea-only provisioning. - require.Contains(t, f.Calls[2].Args["body"], "Gitea-only") - require.NotContains(t, f.Calls[2].Args["body"], "Push-mirror configured") -} - -func TestProjectCreate_UnknownTool(t *testing.T) { - f := &fakeGiteaMCP{} - skill, _ := newSkill(t, f) - _, err := skill.Handle(context.Background(), "nope", happyArgs()) - require.Error(t, err) -} diff --git a/internal/skills/project/skill.go b/internal/skills/project/skill.go deleted file mode 100644 index e43b186..0000000 --- a/internal/skills/project/skill.go +++ /dev/null @@ -1,109 +0,0 @@ -// Package project implements the `project_create` MCP tool: a single-call -// pipeline that creates a Gitea repo from a template, configures push-mirror -// to GitHub, commits a staging namespace manifest to the infra repo, and -// opens an experiment-brief issue on the new repo. See hyperguild gitea -// issue #10 for the design. -package project - -import ( - "context" - "encoding/json" - - "github.com/mathiasbq/supervisor/internal/githubclient" - "github.com/mathiasbq/supervisor/internal/mcpclient" - "github.com/mathiasbq/supervisor/internal/registry" -) - -// Config holds the orchestration dependencies for the project skill. -type Config struct { - // Client talks to the gitea-mcp server. project_create makes - // sequential calls (create_project_from_template, repo_mirror_push, - // file_write_branch, issue_create) through this client. - Client *mcpclient.Client - - // GitHub is the client used to create the empty destination repo on - // GitHub before the push-mirror is configured. Gitea's push-mirror - // cannot push to a non-existent remote, so this step is mandatory - // when GitHubPAT is set. Pass nil to skip github repo creation - // entirely (degraded mode — mirror config will land but the actual - // sync to github will fail until the repo exists). - GitHub *githubclient.Client - - // GiteaOwner is the org/user that owns the new repo and the infra repo - // the namespace manifest is committed to (typically "mathias"). - GiteaOwner string - - // GitHubOwner is the GitHub org/user the push-mirror targets - // (typically "mathiasb"). - GitHubOwner string - - // GitHubPAT is the personal access token used as the push-mirror - // password and to create the destination repo on GitHub. Must have - // `repo` scope. Never logged. - GitHubPAT string - - // InfraRepo is the name of the infra repo on Gitea where the - // k3s/staging//namespace.yaml manifest gets committed - // (typically "infra"). - InfraRepo string -} - -// Skill exposes project_create as an MCP tool. -type Skill struct{ cfg Config } - -// New constructs the project Skill. -func New(cfg Config) *Skill { return &Skill{cfg: cfg} } - -// Name returns the skill identifier. -func (s *Skill) Name() string { return "project" } - -// Tools returns the MCP tool definitions for this skill. -func (s *Skill) Tools() []registry.ToolDef { - schema, _ := json.Marshal(map[string]any{ - "type": "object", - "properties": map[string]any{ - "name": map[string]any{ - "type": "string", - "pattern": `^[a-z][a-z0-9-]{1,38}[a-z0-9]$`, - "description": "Lowercase repo name. 3-40 chars, must start with a letter.", - }, - "description": map[string]any{"type": "string"}, - "hypothesis": map[string]any{"type": "string"}, - "folder": map[string]any{ - "type": "string", - "description": "Informational only — appears in next_steps. Example: AGENTS, AI, QKX.", - }, - "stack": map[string]any{ - "type": "string", - "enum": []string{"go-agent", "go-web"}, - "description": "Selects template-go-agent or template-go-web.", - }, - "private": map[string]any{"type": "boolean"}, - "mirror_to_github": map[string]any{ - "type": "boolean", - "description": "Default false. When true, also create an empty GitHub repo " + - "and configure a push-mirror from Gitea. Opt-in per the Gitea-as-true-master " + - "ADR — only set true for open-source projects (hyperguild, gitea-mcp, template-*). " + - "Never set true for client projects, business logic, or personal experiments.", - }, - }, - "required": []string{"name", "description", "hypothesis", "stack"}, - }) - return []registry.ToolDef{ - { - Name: "project_create", - Description: "Bootstrap a new project: Gitea repo from template, staging namespace manifest, " + - "experiment-brief issue. Optionally mirrors to GitHub when `mirror_to_github: true` " + - "(default false). Idempotent — re-running with an existing repo returns the existing URLs.", - InputSchema: schema, - }, - } -} - -// Handle dispatches the tool call. -func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { - if tool != "project_create" { - return nil, errUnknownTool(tool) - } - return s.handleCreate(ctx, args) -} diff --git a/internal/skills/retrospective/handlers.go b/internal/skills/retrospective/handlers.go deleted file mode 100644 index 913c8fb..0000000 --- a/internal/skills/retrospective/handlers.go +++ /dev/null @@ -1,76 +0,0 @@ -// internal/skills/retrospective/handlers.go -package retrospective - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/mathiasbq/supervisor/internal/session" -) - -type retroArgs struct { - SessionID string `json:"session_id"` - Model string `json:"model,omitempty"` -} - -// Handle dispatches the retrospective tool call. -func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { - if tool != "retrospective" { - return nil, fmt.Errorf("unknown retrospective tool: %s", tool) - } - var a retroArgs - if err := json.Unmarshal(args, &a); err != nil { - return nil, fmt.Errorf("parse args: %w", err) - } - if a.SessionID == "" { - return nil, fmt.Errorf("session_id is required") - } - - model := a.Model - if model == "" { - model = s.cfg.DefaultModel - } - - entries, err := session.Read(s.cfg.SessionsDir, a.SessionID) - if err != nil { - return nil, fmt.Errorf("read session log: %w", err) - } - - logJSON, err := json.MarshalIndent(entries, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal session log: %w", err) - } - - taskPrompt := fmt.Sprintf( - "SESSION_ID: %s\n\nSESSION_LOG:\n%s\n\nReview this session log. Identify what is novel or worth preserving as organizational knowledge. Provide structured insights.", - a.SessionID, string(logJSON), - ) - - if s.cfg.CompleteFunc == nil { - return nil, fmt.Errorf("no executor configured") - } - t0 := time.Now() - text, dur, err := s.cfg.CompleteFunc(ctx, model, s.cfg.SkillPrompt, taskPrompt) - if err != nil { - return nil, fmt.Errorf("retrospective model: %w", err) - } - - msg := text - if len(msg) > 200 { - msg = msg[:200] - } - _ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{ - SessionID: a.SessionID, - Timestamp: time.Now(), - Skill: "retrospective", - Phase: "retrospective", - FinalStatus: "ok", - ModelUsed: model, - DurationMs: time.Since(t0).Milliseconds(), - Message: msg, - }) - - return json.Marshal(map[string]any{"text": text, "model": model, "duration_ms": dur}) -} diff --git a/internal/skills/retrospective/handlers_test.go b/internal/skills/retrospective/handlers_test.go deleted file mode 100644 index 4842ba2..0000000 --- a/internal/skills/retrospective/handlers_test.go +++ /dev/null @@ -1,41 +0,0 @@ -// internal/skills/retrospective/handlers_test.go -package retrospective_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/mathiasbq/supervisor/internal/skills/retrospective" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestHandle_Retrospective_RequiresSessionID(t *testing.T) { - s := retrospective.New(retrospective.Config{}) - _, err := s.Handle(context.Background(), "retrospective", json.RawMessage(`{}`)) - assert.Error(t, err) - assert.Contains(t, err.Error(), "session_id") -} - -func TestHandle_Retrospective_BuildsPromptWithSessionLog(t *testing.T) { - var capturedTask string - s := retrospective.New(retrospective.Config{ - SkillPrompt: "retrospective discipline", - DefaultModel: "ollama/test", - SessionsDir: t.TempDir(), - CompleteFunc: func(_ context.Context, _, _, user string) (string, int64, error) { - capturedTask = user - return "Key insight: the team resolved a tricky nil pointer issue via careful logging.", 75, nil - }, - }) - - args, _ := json.Marshal(map[string]string{"session_id": "empty-session"}) - out, err := s.Handle(context.Background(), "retrospective", args) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(out, &result)) - assert.Contains(t, result["text"], "nil pointer") - assert.Contains(t, capturedTask, "empty-session") -} diff --git a/internal/skills/retrospective/skill.go b/internal/skills/retrospective/skill.go deleted file mode 100644 index 5106742..0000000 --- a/internal/skills/retrospective/skill.go +++ /dev/null @@ -1,49 +0,0 @@ -// internal/skills/retrospective/skill.go -package retrospective - -import ( - "context" - "encoding/json" - - "github.com/mathiasbq/supervisor/internal/registry" -) - -// CompleteFunc is the function used to call a local model. -type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error) - -// Config holds retrospective skill configuration. -type Config struct { - SkillPrompt string - DefaultModel string - SessionsDir string - CompleteFunc CompleteFunc -} - -// Skill implements registry.Skill for the retrospective tool. -type Skill struct { - cfg Config -} - -// New constructs a retrospective Skill. -func New(cfg Config) *Skill { return &Skill{cfg: cfg} } - -// Name returns the skill name. -func (s *Skill) Name() string { return "retrospective" } - -// Tools returns the MCP tool definitions. -func (s *Skill) Tools() []registry.ToolDef { - return []registry.ToolDef{ - { - Name: "retrospective", - Description: "Consult a local model to analyse a completed session and identify what is novel or worth preserving as organizational knowledge.", - InputSchema: json.RawMessage(`{ - "type": "object", - "required": ["session_id"], - "properties": { - "session_id": {"type": "string"}, - "model": {"type": "string"} - } - }`), - }, - } -} diff --git a/internal/skills/review/handlers.go b/internal/skills/review/handlers.go deleted file mode 100644 index 2e0d701..0000000 --- a/internal/skills/review/handlers.go +++ /dev/null @@ -1,83 +0,0 @@ -// internal/skills/review/handlers.go -package review - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/mathiasbq/supervisor/internal/brain" - "github.com/mathiasbq/supervisor/internal/session" -) - -type reviewArgs struct { - ProjectRoot string `json:"project_root"` - Files []string `json:"files"` - Context string `json:"context"` - Model string `json:"model"` - SessionID string `json:"session_id"` -} - -// Handle dispatches the MCP tool call to the appropriate handler. -func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { - if tool != "review" { - return nil, fmt.Errorf("unknown tool: %s", tool) - } - var a reviewArgs - if err := json.Unmarshal(args, &a); err != nil { - return nil, fmt.Errorf("parse args: %w", err) - } - if a.ProjectRoot == "" { - return nil, fmt.Errorf("project_root is required") - } - if len(a.Files) == 0 { - return nil, fmt.Errorf("files is required") - } - - model := a.Model - if model == "" { - model = s.cfg.DefaultModel - } - - brainCtx, _ := brain.Query(ctx, s.cfg.IngestBaseURL, strings.Join(a.Files, " ")+" "+a.Context, 3) - - task := fmt.Sprintf( - "phase: review\nproject_root: %s\nfiles: %s\ncontext: %s\nmodel: %s", - a.ProjectRoot, strings.Join(a.Files, ", "), a.Context, model, - ) - task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "review", task) - if brainCtx != "" { - task = brainCtx + "\n---\n\n" + task - } - - if s.cfg.CompleteFunc == nil { - return nil, fmt.Errorf("no executor configured") - } - t0 := time.Now() - text, dur, err := s.cfg.CompleteFunc(ctx, model, s.cfg.SkillPrompt, task) - if err != nil { - return nil, err - } - - if a.SessionID != "" && s.cfg.SessionsDir != "" { - msg := text - if len(msg) > 200 { - msg = msg[:200] - } - _ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{ - SessionID: a.SessionID, - Timestamp: time.Now(), - Skill: "review", - Phase: "review", - ProjectRoot: a.ProjectRoot, - FinalStatus: "ok", - ModelUsed: model, - DurationMs: time.Since(t0).Milliseconds(), - Message: msg, - }) - } - - return json.Marshal(map[string]any{"text": text, "model": model, "duration_ms": dur}) -} diff --git a/internal/skills/review/handlers_test.go b/internal/skills/review/handlers_test.go deleted file mode 100644 index 67ffeb7..0000000 --- a/internal/skills/review/handlers_test.go +++ /dev/null @@ -1,53 +0,0 @@ -// internal/skills/review/handlers_test.go -package review_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/mathiasbq/supervisor/internal/skills/review" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestReviewToolRegistered(t *testing.T) { - sk := review.New(review.Config{SkillPrompt: "review rules"}) - names := make([]string, 0) - for _, tool := range sk.Tools() { - names = append(names, tool.Name) - } - assert.Contains(t, names, "review") -} - -func TestReviewRequiresProjectRoot(t *testing.T) { - sk := review.New(review.Config{SkillPrompt: "r"}) - _, err := sk.Handle(context.Background(), "review", json.RawMessage(`{"files":["main.go"]}`)) - assert.ErrorContains(t, err, "project_root") -} - -func TestReviewRequiresFiles(t *testing.T) { - sk := review.New(review.Config{SkillPrompt: "r"}) - _, err := sk.Handle(context.Background(), "review", json.RawMessage(`{"project_root":"/tmp"}`)) - assert.ErrorContains(t, err, "files") -} - -func TestReviewCallsCompleteFunc(t *testing.T) { - var capturedTask string - fakeFn := func(_ context.Context, _, _, user string) (string, int64, error) { - capturedTask = user - return "2 warnings found: missing error handling at line 42", 80, nil - } - - sk := review.New(review.Config{SkillPrompt: "review rules", CompleteFunc: fakeFn, SessionsDir: t.TempDir()}) - out, err := sk.Handle(context.Background(), "review", json.RawMessage( - `{"project_root":"/tmp/proj","files":["internal/foo/foo.go"],"context":"PR: add Foo helper"}`, - )) - require.NoError(t, err) - assert.Contains(t, capturedTask, "internal/foo/foo.go") - assert.Contains(t, capturedTask, "PR: add Foo helper") - - var result map[string]any - require.NoError(t, json.Unmarshal(out, &result)) - assert.Contains(t, result["text"], "2 warnings found") -} diff --git a/internal/skills/review/skill.go b/internal/skills/review/skill.go deleted file mode 100644 index 361c666..0000000 --- a/internal/skills/review/skill.go +++ /dev/null @@ -1,55 +0,0 @@ -// internal/skills/review/skill.go -package review - -import ( - "context" - "encoding/json" - - "github.com/mathiasbq/supervisor/internal/registry" -) - -// CompleteFunc is the function used to call a local model. -type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error) - -// Config holds dependencies for the review skill. -type Config struct { - SkillPrompt string - DefaultModel string - CompleteFunc CompleteFunc - SessionsDir string - IngestBaseURL string -} - -// Skill implements the review MCP tool. -type Skill struct{ cfg Config } - -// New creates a new review Skill. -func New(cfg Config) *Skill { return &Skill{cfg: cfg} } - -// Name returns the skill identifier. -func (s *Skill) Name() string { return "review" } - -// Tools returns the MCP tool definitions for this skill. -func (s *Skill) Tools() []registry.ToolDef { - schema := func(required []string, props map[string]any) json.RawMessage { - b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props}) - return b - } - str := map[string]any{"type": "string"} - return []registry.ToolDef{ - { - Name: "review", - Description: "Consult a local model for a structured code review of the specified files. Returns findings with severity levels.", - InputSchema: schema( - []string{"project_root", "files"}, - map[string]any{ - "project_root": str, - "files": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, - "context": str, - "model": str, - "session_id": str, - }, - ), - }, - } -} diff --git a/internal/skills/trainer/handlers.go b/internal/skills/trainer/handlers.go deleted file mode 100644 index 71c85a9..0000000 --- a/internal/skills/trainer/handlers.go +++ /dev/null @@ -1,87 +0,0 @@ -// internal/skills/trainer/handlers.go -package trainer - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/mathiasbq/supervisor/internal/session" -) - -type trainArgs struct { - SessionID string `json:"session_id"` - Model string `json:"model"` -} - -// Handle dispatches the MCP tool call to the trainer handler. -func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { - if tool != "trainer" { - return nil, fmt.Errorf("unknown tool: %s", tool) - } - var a trainArgs - if err := json.Unmarshal(args, &a); err != nil { - return nil, fmt.Errorf("parse args: %w", err) - } - if a.SessionID == "" { - return nil, fmt.Errorf("session_id is required") - } - if s.cfg.CompleteFunc == nil { - return nil, fmt.Errorf("no executor configured") - } - - model := a.Model - if model == "" { - model = s.cfg.DefaultModel - } - - entries, err := session.Read(s.cfg.SessionsDir, a.SessionID) - if err != nil { - return nil, fmt.Errorf("read session log: %w", err) - } - - // ── Step 1: Reader ──────────────────────────────────────────────────────── - history := session.FormatHistory(entries, "") - readerTask := fmt.Sprintf( - "role: reader\nsession_id: %s\nbrain_dir: %s\n\n%s", - a.SessionID, s.cfg.BrainDir, history, - ) - readerText, _, err := s.cfg.CompleteFunc(ctx, model, s.cfg.ReaderPrompt, readerTask) - if err != nil { - return nil, fmt.Errorf("reader: %w", err) - } - - // ── Step 2: Writer (receives reader output) ─────────────────────────────── - t0 := time.Now() - writerTask := fmt.Sprintf( - "role: writer\nsession_id: %s\nbrain_dir: %s\n\nreader_analysis:\n%s", - a.SessionID, s.cfg.BrainDir, readerText, - ) - writerText, dur, err := s.cfg.CompleteFunc(ctx, model, s.cfg.WriterPrompt, writerTask) - if err != nil { - return nil, fmt.Errorf("writer: %w", err) - } - - msg := writerText - if len(msg) > 200 { - msg = msg[:200] - } - _ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{ - SessionID: a.SessionID, - Timestamp: time.Now(), - Skill: "trainer", - Phase: "trainer", - FinalStatus: "ok", - ModelUsed: model, - DurationMs: time.Since(t0).Milliseconds(), - Message: msg, - }) - - return json.Marshal(map[string]any{ - "reader_analysis": readerText, - "writer_output": writerText, - "model": model, - "duration_ms": dur, - }) -} diff --git a/internal/skills/trainer/handlers_test.go b/internal/skills/trainer/handlers_test.go deleted file mode 100644 index a9370aa..0000000 --- a/internal/skills/trainer/handlers_test.go +++ /dev/null @@ -1,73 +0,0 @@ -// internal/skills/trainer/handlers_test.go -package trainer_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/mathiasbq/supervisor/internal/session" - "github.com/mathiasbq/supervisor/internal/skills/trainer" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestTrainerToolRegistered(t *testing.T) { - sk := trainer.New(trainer.Config{ReaderPrompt: "r", WriterPrompt: "w"}) - names := make([]string, 0) - for _, tool := range sk.Tools() { - names = append(names, tool.Name) - } - assert.Contains(t, names, "trainer") -} - -func TestTrainerRequiresSessionID(t *testing.T) { - sk := trainer.New(trainer.Config{ReaderPrompt: "r", WriterPrompt: "w"}) - _, err := sk.Handle(context.Background(), "trainer", json.RawMessage(`{}`)) - assert.ErrorContains(t, err, "session_id") -} - -func TestTrainerCallsReaderThenWriter(t *testing.T) { - sessDir := t.TempDir() - require.NoError(t, session.Append(sessDir, "sess-1", session.Entry{ - SessionID: "sess-1", Skill: "tdd", Phase: "red", FinalStatus: "ok", - Message: "wrote failing test", FilePath: "internal/foo/foo_test.go", - })) - - callCount := 0 - var readerTask, writerTask string - - fakeFn := func(_ context.Context, _, sys, user string) (string, int64, error) { - callCount++ - if callCount == 1 { - // reader call - readerTask = user - return "1 sft candidate found: first-pass clean TDD", 60, nil - } - // writer call - writerTask = user - return "written 1 knowledge entry to brain/knowledge/tdd-patterns.md", 70, nil - } - - sk := trainer.New(trainer.Config{ - ReaderPrompt: "reader rules", - WriterPrompt: "writer rules", - CompleteFunc: fakeFn, - SessionsDir: sessDir, - BrainDir: t.TempDir(), - }) - out, err := sk.Handle(context.Background(), "trainer", json.RawMessage(`{"session_id":"sess-1"}`)) - require.NoError(t, err) - - assert.Equal(t, 2, callCount, "complete must be called exactly twice: reader then writer") - assert.Contains(t, readerTask, "role: reader") - assert.Contains(t, readerTask, "sess-1") - assert.Contains(t, readerTask, "wrote failing test") - assert.Contains(t, writerTask, "role: writer") - assert.Contains(t, writerTask, "sft candidate") - - var result map[string]any - require.NoError(t, json.Unmarshal(out, &result)) - assert.Contains(t, result["reader_analysis"], "sft candidate") - assert.Contains(t, result["writer_output"], "knowledge entry") -} diff --git a/internal/skills/trainer/skill.go b/internal/skills/trainer/skill.go deleted file mode 100644 index f37164e..0000000 --- a/internal/skills/trainer/skill.go +++ /dev/null @@ -1,52 +0,0 @@ -// internal/skills/trainer/skill.go -package trainer - -import ( - "context" - "encoding/json" - - "github.com/mathiasbq/supervisor/internal/registry" -) - -// CompleteFunc is the function used to call a local model. -type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error) - -// Config holds dependencies for the trainer skill. -type Config struct { - ReaderPrompt string - WriterPrompt string - DefaultModel string - CompleteFunc CompleteFunc - SessionsDir string - BrainDir string // root of brain/ directory -} - -// Skill implements the trainer MCP tool. -type Skill struct{ cfg Config } - -// New creates a new trainer Skill. -func New(cfg Config) *Skill { return &Skill{cfg: cfg} } - -// Name returns the skill identifier. -func (s *Skill) Name() string { return "trainer" } - -// Tools returns the MCP tool definitions for this skill. -func (s *Skill) Tools() []registry.ToolDef { - schema := func(required []string, props map[string]any) json.RawMessage { - b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props}) - return b - } - return []registry.ToolDef{ - { - Name: "trainer", - Description: "Consult a local model to identify learning moments from a session log and suggest knowledge to preserve in the brain.", - InputSchema: schema( - []string{"session_id"}, - map[string]any{ - "session_id": map[string]any{"type": "string"}, - "model": map[string]any{"type": "string"}, - }, - ), - }, - } -}