diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ebbab6c..0000000 --- a/Dockerfile +++ /dev/null @@ -1,50 +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/supervisor ./cmd/supervisor - -# ── Runtime stage ───────────────────────────────────────────────────────────── -# Node.js 22 slim — needed for claude CLI subprocess -FROM node:22-slim - -# Install claude CLI (provides the `claude` binary the supervisor shells out to) -RUN npm install -g @anthropic-ai/claude-code \ - && claude --version \ - && echo "claude CLI installed" - -# Copy supervisor binary -COPY --from=builder /out/supervisor /usr/local/bin/supervisor - -# Bake in config (models.yaml + skill discipline files) -COPY config/ /app/config/ - -# Run as non-root -RUN groupadd -r supervisor && useradd -r -g supervisor -d /app supervisor - -WORKDIR /app - -# brain/ is writable state — mount a PersistentVolume here -VOLUME /app/brain - -ENV SUPERVISOR_CONFIG_DIR=/app/config/supervisor -ENV SUPERVISOR_MODELS_FILE=/app/config/models.yaml -ENV SUPERVISOR_BRAIN_DIR=/app/brain -ENV SUPERVISOR_SESSIONS_DIR=/app/brain/sessions -ENV SUPERVISOR_PORT=3200 - -USER supervisor - -EXPOSE 3200 - -ENTRYPOINT ["/usr/local/bin/supervisor"] diff --git a/cmd/supervisor/main.go b/cmd/supervisor/main.go deleted file mode 100644 index 66232a7..0000000 --- a/cmd/supervisor/main.go +++ /dev/null @@ -1,182 +0,0 @@ -package main - -import ( - "context" - "log/slog" - "net/http" - "os" - - "github.com/mathiasbq/supervisor/internal/auth" - "github.com/mathiasbq/supervisor/internal/config" - iexec "github.com/mathiasbq/supervisor/internal/exec" - "github.com/mathiasbq/supervisor/internal/mcp" - "github.com/mathiasbq/supervisor/internal/registry" - "github.com/mathiasbq/supervisor/internal/skills/brain" - "github.com/mathiasbq/supervisor/internal/skills/org" - "github.com/mathiasbq/supervisor/internal/skills/retrospective" - skilldebug "github.com/mathiasbq/supervisor/internal/skills/debug" - "github.com/mathiasbq/supervisor/internal/skills/review" - "github.com/mathiasbq/supervisor/internal/skills/spec" - "github.com/mathiasbq/supervisor/internal/skills/trainer" - "github.com/mathiasbq/supervisor/internal/skills/sessionlog" - "github.com/mathiasbq/supervisor/internal/skills/tdd" - "github.com/mathiasbq/supervisor/internal/tier" -) - -func main() { - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - - cfg, err := config.Load() - if err != nil { - logger.Error("load config", "err", err) - os.Exit(1) - } - - models, err := config.LoadModels(cfg.ModelsFile) - if err != nil { - logger.Error("load models", "err", err) - os.Exit(1) - } - - protocolsPrompt, err := os.ReadFile(cfg.ConfigDir + "/protocols.md") - if err != nil { - logger.Error("read protocols.md", "path", cfg.ConfigDir+"/protocols.md", "err", err) - os.Exit(1) - } - - // prependProtocols prepends the shared protocols to a skill discipline file. - prependProtocols := func(skillPrompt []byte) string { - return string(protocolsPrompt) + "\n---\n\n" + string(skillPrompt) - } - - tddPrompt, err := os.ReadFile(cfg.ConfigDir + "/tdd.md") - if err != nil { - logger.Error("read tdd.md", "path", cfg.ConfigDir+"/tdd.md", "err", err) - os.Exit(1) - } - - retroPrompt, err := os.ReadFile(cfg.ConfigDir + "/retrospective.md") - if err != nil { - logger.Error("read retrospective.md", "path", cfg.ConfigDir+"/retrospective.md", "err", err) - os.Exit(1) - } - - reviewPrompt, err := os.ReadFile(cfg.ConfigDir + "/review.md") - if err != nil { - logger.Error("read review.md", "path", cfg.ConfigDir+"/review.md", "err", err) - os.Exit(1) - } - - debugPrompt, err := os.ReadFile(cfg.ConfigDir + "/debug.md") - if err != nil { - logger.Error("read debug.md", "path", cfg.ConfigDir+"/debug.md", "err", err) - os.Exit(1) - } - - specPrompt, err := os.ReadFile(cfg.ConfigDir + "/spec.md") - if err != nil { - logger.Error("read spec.md", "path", cfg.ConfigDir+"/spec.md", "err", err) - os.Exit(1) - } - - trainerReaderPrompt, err := os.ReadFile(cfg.ConfigDir + "/trainer-reader.md") - if err != nil { - logger.Error("read trainer-reader.md", "path", cfg.ConfigDir+"/trainer-reader.md", "err", err) - os.Exit(1) - } - trainerWriterPrompt, err := os.ReadFile(cfg.ConfigDir + "/trainer-writer.md") - if err != nil { - logger.Error("read trainer-writer.md", "path", cfg.ConfigDir+"/trainer-writer.md", "err", err) - os.Exit(1) - } - - litellm := iexec.NewLiteLLM(cfg.LiteLLMBaseURL, cfg.LiteLLMAPIKey, 0) - - tierFn := func(ctx context.Context) tier.Info { - return tier.Detect(ctx, "https://api.anthropic.com", cfg.LiteLLMBaseURL) - } - - reg := registry.New() - reg.Register(tdd.New(tdd.Config{ - SkillPrompt: prependProtocols(tddPrompt), - DefaultModel: models.ModelFor("tdd", ""), - CompleteFunc: litellm.Complete, - SessionsDir: cfg.SessionsDir, - IngestBaseURL: cfg.IngestBaseURL, - })) - reg.Register(brain.New(brain.Config{ - IngestBaseURL: cfg.IngestBaseURL, - IngestSvcURL: cfg.IngestSvcURL, - KBRetrievalURL: cfg.KBRetrievalURL, - })) - reg.Register(org.New(org.Config{ - TierFn: tierFn, - })) - reg.Register(sessionlog.New(sessionlog.Config{ - SessionsDir: cfg.SessionsDir, - })) - reg.Register(retrospective.New(retrospective.Config{ - SkillPrompt: prependProtocols(retroPrompt), - DefaultModel: models.ModelFor("retrospective", ""), - SessionsDir: cfg.SessionsDir, - CompleteFunc: litellm.Complete, - })) - reg.Register(review.New(review.Config{ - SkillPrompt: prependProtocols(reviewPrompt), - DefaultModel: models.ModelFor("review", ""), - CompleteFunc: litellm.Complete, - SessionsDir: cfg.SessionsDir, - IngestBaseURL: cfg.IngestBaseURL, - })) - reg.Register(skilldebug.New(skilldebug.Config{ - SkillPrompt: prependProtocols(debugPrompt), - DefaultModel: models.ModelFor("debug", ""), - CompleteFunc: litellm.Complete, - SessionsDir: cfg.SessionsDir, - IngestBaseURL: cfg.IngestBaseURL, - })) - reg.Register(spec.New(spec.Config{ - SkillPrompt: prependProtocols(specPrompt), - DefaultModel: models.ModelFor("spec", ""), - CompleteFunc: litellm.Complete, - SessionsDir: cfg.SessionsDir, - IngestBaseURL: cfg.IngestBaseURL, - })) - reg.Register(trainer.New(trainer.Config{ - ReaderPrompt: prependProtocols(trainerReaderPrompt), - WriterPrompt: prependProtocols(trainerWriterPrompt), - DefaultModel: models.ModelFor("trainer", ""), - CompleteFunc: litellm.Complete, - SessionsDir: cfg.SessionsDir, - BrainDir: cfg.BrainDir, - })) - - 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) - - 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("supervisor starting", "addr", addr, "version", "v0.5.0") - if err := http.ListenAndServe(addr, mux); err != nil { - logger.Error("server stopped", "err", err) - os.Exit(1) - } -} diff --git a/cmd/supervisor/main_test.go b/cmd/supervisor/main_test.go deleted file mode 100644 index 87cf84c..0000000 --- a/cmd/supervisor/main_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package main - -import ( - "os/exec" - "testing" -) - -func TestBinaryCompiles(t *testing.T) { - cmd := exec.Command("go", "build", "./...") - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("build failed: %s\n%s", err, out) - } -} diff --git a/internal/skills/spec/handlers.go b/internal/skills/spec/handlers.go deleted file mode 100644 index 471afd8..0000000 --- a/internal/skills/spec/handlers.go +++ /dev/null @@ -1,87 +0,0 @@ -// internal/skills/spec/handlers.go -package spec - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/mathiasbq/supervisor/internal/brain" - "github.com/mathiasbq/supervisor/internal/session" -) - -type specArgs struct { - ProjectRoot string `json:"project_root"` - Requirements string `json:"requirements"` - OutputPath string `json:"output_path"` - 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 != "spec" { - return nil, fmt.Errorf("unknown tool: %s", tool) - } - var a specArgs - 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.Requirements == "" { - return nil, fmt.Errorf("requirements is required") - } - outputPath := a.OutputPath - if outputPath == "" { - outputPath = "docs/spec.md" - } - - model := a.Model - if model == "" { - model = s.cfg.DefaultModel - } - - brainCtx, _ := brain.Query(ctx, s.cfg.IngestBaseURL, a.Requirements+" "+a.Context, 3) - - task := fmt.Sprintf( - "phase: spec\nproject_root: %s\nrequirements: %s\noutput_path: %s\ncontext: %s\nmodel: %s", - a.ProjectRoot, a.Requirements, outputPath, a.Context, model, - ) - task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "spec", 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: "spec", - Phase: "spec", - 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/spec/handlers_test.go b/internal/skills/spec/handlers_test.go deleted file mode 100644 index 3e864d2..0000000 --- a/internal/skills/spec/handlers_test.go +++ /dev/null @@ -1,53 +0,0 @@ -// internal/skills/spec/handlers_test.go -package spec_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/mathiasbq/supervisor/internal/skills/spec" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSpecToolRegistered(t *testing.T) { - sk := spec.New(spec.Config{SkillPrompt: "spec rules"}) - names := make([]string, 0) - for _, tool := range sk.Tools() { - names = append(names, tool.Name) - } - assert.Contains(t, names, "spec") -} - -func TestSpecRequiresProjectRoot(t *testing.T) { - sk := spec.New(spec.Config{SkillPrompt: "s"}) - _, err := sk.Handle(context.Background(), "spec", json.RawMessage(`{"requirements":"add login"}`)) - assert.ErrorContains(t, err, "project_root") -} - -func TestSpecRequiresRequirements(t *testing.T) { - sk := spec.New(spec.Config{SkillPrompt: "s"}) - _, err := sk.Handle(context.Background(), "spec", json.RawMessage(`{"project_root":"/tmp"}`)) - assert.ErrorContains(t, err, "requirements") -} - -func TestSpecCallsCompleteFunc(t *testing.T) { - var capturedTask string - fakeFn := func(_ context.Context, _, _, user string) (string, int64, error) { - capturedTask = user - return "# OAuth2 Login Spec\n\n## Overview\nImplement OAuth2 login flow.", 110, nil - } - - sk := spec.New(spec.Config{SkillPrompt: "spec rules", CompleteFunc: fakeFn, SessionsDir: t.TempDir()}) - out, err := sk.Handle(context.Background(), "spec", json.RawMessage( - `{"project_root":"/tmp/proj","requirements":"add OAuth2 login","output_path":"docs/login-spec.md"}`, - )) - require.NoError(t, err) - assert.Contains(t, capturedTask, "OAuth2 login") - assert.Contains(t, capturedTask, "docs/login-spec.md") - - var result map[string]any - require.NoError(t, json.Unmarshal(out, &result)) - assert.Contains(t, result["text"], "OAuth2 Login Spec") -} diff --git a/internal/skills/spec/skill.go b/internal/skills/spec/skill.go deleted file mode 100644 index 461b886..0000000 --- a/internal/skills/spec/skill.go +++ /dev/null @@ -1,56 +0,0 @@ -// internal/skills/spec/skill.go -package spec - -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 spec skill. -type Config struct { - SkillPrompt string - DefaultModel string - CompleteFunc CompleteFunc - SessionsDir string - IngestBaseURL string -} - -// Skill implements the spec MCP tool. -type Skill struct{ cfg Config } - -// New creates a new spec Skill. -func New(cfg Config) *Skill { return &Skill{cfg: cfg} } - -// Name returns the skill identifier. -func (s *Skill) Name() string { return "spec" } - -// 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: "spec", - Description: "Consult a local model to draft a structured implementation spec from requirements. Returns the spec text.", - InputSchema: schema( - []string{"project_root", "requirements"}, - map[string]any{ - "project_root": str, - "requirements": str, - "output_path": str, - "context": str, - "model": str, - "session_id": str, - }, - ), - }, - } -} diff --git a/internal/skills/tdd/handlers.go b/internal/skills/tdd/handlers.go deleted file mode 100644 index f897fc6..0000000 --- a/internal/skills/tdd/handlers.go +++ /dev/null @@ -1,173 +0,0 @@ -package tdd - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/mathiasbq/supervisor/internal/brain" - "github.com/mathiasbq/supervisor/internal/session" -) - -func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { - switch tool { - case "tdd_red": - return s.handleRed(ctx, args) - case "tdd_green": - return s.handleGreen(ctx, args) - case "tdd_refactor": - return s.handleRefactor(ctx, args) - default: - return nil, fmt.Errorf("unknown tool: %s", tool) - } -} - -type redArgs struct { - ProjectRoot string `json:"project_root"` - Spec string `json:"spec"` - Model string `json:"model"` - TestCmd string `json:"test_cmd"` -} - -func (s *Skill) handleRed(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { - var args redArgs - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("parse args: %w", err) - } - if args.ProjectRoot == "" { - return nil, fmt.Errorf("project_root is required") - } - if args.Spec == "" { - return nil, fmt.Errorf("spec is required") - } - brainCtx, _ := brain.Query(ctx, s.cfg.IngestBaseURL, args.Spec, 3) - - task := fmt.Sprintf( - "phase: red\nproject_root: %s\nspec: %s\nmodel: %s\ntest_cmd: %s", - args.ProjectRoot, args.Spec, s.resolveModel(args.Model), args.TestCmd, - ) - if brainCtx != "" { - task = brainCtx + "\n---\n\n" + task - } - return s.complete(ctx, s.resolveModel(args.Model), task) -} - -type greenArgs struct { - ProjectRoot string `json:"project_root"` - TestPath string `json:"test_path"` - Model string `json:"model"` - TestCmd string `json:"test_cmd"` - SessionID string `json:"session_id"` -} - -func (s *Skill) handleGreen(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { - var args greenArgs - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("parse args: %w", err) - } - if args.ProjectRoot == "" { - return nil, fmt.Errorf("project_root is required") - } - if args.TestPath == "" { - return nil, fmt.Errorf("test_path is required") - } - task := fmt.Sprintf( - "phase: green\nproject_root: %s\ntest_path: %s\nmodel: %s\ntest_cmd: %s", - args.ProjectRoot, args.TestPath, s.resolveModel(args.Model), args.TestCmd, - ) - task = session.PrependHistory(s.cfg.SessionsDir, args.SessionID, "green", task) - - t0 := time.Now() - result, err := s.complete(ctx, s.resolveModel(args.Model), task) - if err != nil { - return nil, err - } - s.logEntry(args.SessionID, args.ProjectRoot, "tdd", "green", s.resolveModel(args.Model), t0, result) - return result, nil -} - -type refactorArgs struct { - ProjectRoot string `json:"project_root"` - TestPath string `json:"test_path"` - ImplPath string `json:"impl_path"` - Model string `json:"model"` - TestCmd string `json:"test_cmd"` - SessionID string `json:"session_id"` -} - -func (s *Skill) handleRefactor(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { - var args refactorArgs - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("parse args: %w", err) - } - if args.ProjectRoot == "" { - return nil, fmt.Errorf("project_root is required") - } - if args.TestPath == "" { - return nil, fmt.Errorf("test_path is required") - } - if args.ImplPath == "" { - return nil, fmt.Errorf("impl_path is required") - } - task := fmt.Sprintf( - "phase: refactor\nproject_root: %s\ntest_path: %s\nimpl_path: %s\nmodel: %s\ntest_cmd: %s", - args.ProjectRoot, args.TestPath, args.ImplPath, s.resolveModel(args.Model), args.TestCmd, - ) - task = session.PrependHistory(s.cfg.SessionsDir, args.SessionID, "refactor", task) - - t0 := time.Now() - result, err := s.complete(ctx, s.resolveModel(args.Model), task) - if err != nil { - return nil, err - } - s.logEntry(args.SessionID, args.ProjectRoot, "tdd", "refactor", s.resolveModel(args.Model), t0, result) - return result, nil -} - -func (s *Skill) resolveModel(override string) string { - if override != "" { - return override - } - return s.cfg.DefaultModel -} - -// complete calls CompleteFunc and returns the text as JSON. -func (s *Skill) complete(ctx context.Context, model, task string) (json.RawMessage, error) { - if s.cfg.CompleteFunc == nil { - return nil, fmt.Errorf("no executor configured") - } - text, dur, err := s.cfg.CompleteFunc(ctx, model, s.cfg.SkillPrompt, task) - if err != nil { - return nil, err - } - return json.Marshal(map[string]any{"text": text, "model": model, "duration_ms": dur}) -} - -// logEntry writes a session.Entry for a completed phase if session_id is set. -func (s *Skill) logEntry(sessionID, projectRoot, skill, phase, model string, t0 time.Time, raw json.RawMessage) { - if sessionID == "" || s.cfg.SessionsDir == "" { - return - } - var msg string - var result struct { - Text string `json:"text"` - } - if err := json.Unmarshal(raw, &result); err == nil && len(result.Text) > 0 { - msg = result.Text - if len(msg) > 200 { - msg = msg[:200] - } - } - _ = session.Append(s.cfg.SessionsDir, sessionID, session.Entry{ - SessionID: sessionID, - Timestamp: time.Now(), - Skill: skill, - Phase: phase, - ProjectRoot: projectRoot, - FinalStatus: "ok", - ModelUsed: model, - DurationMs: time.Since(t0).Milliseconds(), - Message: msg, - }) -} diff --git a/internal/skills/tdd/handlers_test.go b/internal/skills/tdd/handlers_test.go deleted file mode 100644 index ab0f1d5..0000000 --- a/internal/skills/tdd/handlers_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package tdd_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/mathiasbq/supervisor/internal/session" - "github.com/mathiasbq/supervisor/internal/skills/tdd" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestTDDSkillTools(t *testing.T) { - skill := tdd.New(tdd.Config{ - SkillPrompt: "tdd rules", - }) - tools := skill.Tools() - names := make([]string, len(tools)) - for i, tool := range tools { - names[i] = tool.Name - } - assert.ElementsMatch(t, []string{"tdd_red", "tdd_green", "tdd_refactor"}, names) -} - -func TestTDDSkillHandleUnknown(t *testing.T) { - skill := tdd.New(tdd.Config{SkillPrompt: "t"}) - _, err := skill.Handle(context.Background(), "tdd_unknown", json.RawMessage(`{}`)) - assert.ErrorContains(t, err, "unknown tool") -} - -func TestTDDRedRequiresProjectRoot(t *testing.T) { - skill := tdd.New(tdd.Config{SkillPrompt: "t"}) - _, err := skill.Handle(context.Background(), "tdd_red", json.RawMessage(`{"spec":"add two numbers"}`)) - assert.ErrorContains(t, err, "project_root") -} - -func TestTDDRedRequiresSpec(t *testing.T) { - skill := tdd.New(tdd.Config{SkillPrompt: "t"}) - _, err := skill.Handle(context.Background(), "tdd_red", json.RawMessage(`{"project_root":"/tmp/proj"}`)) - assert.ErrorContains(t, err, "spec") -} - -func TestTDDGreenInjectsSessionHistory(t *testing.T) { - sessDir := t.TempDir() - require.NoError(t, session.Append(sessDir, "sess-1", session.Entry{ - SessionID: "sess-1", Skill: "tdd", Phase: "red", FinalStatus: "pass", - FilePath: "internal/foo/foo_test.go", - Message: "wrote failing test for Foo", - })) - - var capturedTask string - fakeFn := func(_ context.Context, _, _, user string) (string, int64, error) { - capturedTask = user - return "here is my suggestion", 100, nil - } - - sk := tdd.New(tdd.Config{SkillPrompt: "tdd", CompleteFunc: fakeFn, SessionsDir: sessDir}) - _, err := sk.Handle(context.Background(), "tdd_green", json.RawMessage( - `{"project_root":"/tmp","test_path":"internal/foo/foo_test.go","test_cmd":"go test ./...","session_id":"sess-1"}`, - )) - require.NoError(t, err) - assert.Contains(t, capturedTask, "## Session history") - assert.Contains(t, capturedTask, "wrote failing test for Foo") -} - -func TestTDDGreenNoHistoryWhenSessionIDEmpty(t *testing.T) { - var capturedTask string - fakeFn := func(_ context.Context, _, _, user string) (string, int64, error) { - capturedTask = user - return "suggestion", 50, nil - } - - sk := tdd.New(tdd.Config{SkillPrompt: "tdd", CompleteFunc: fakeFn, SessionsDir: t.TempDir()}) - _, err := sk.Handle(context.Background(), "tdd_green", json.RawMessage( - `{"project_root":"/tmp","test_path":"internal/foo/foo_test.go"}`, - )) - require.NoError(t, err) - assert.NotContains(t, capturedTask, "## Session history") -} - -func TestTDDGreenReturnsTextJSON(t *testing.T) { - fakeFn := func(_ context.Context, _, _, _ string) (string, int64, error) { - return "write a func that adds two ints", 42, nil - } - - sk := tdd.New(tdd.Config{SkillPrompt: "tdd", CompleteFunc: fakeFn}) - raw, err := sk.Handle(context.Background(), "tdd_green", json.RawMessage( - `{"project_root":"/tmp","test_path":"foo_test.go"}`, - )) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(raw, &result)) - assert.Equal(t, "write a func that adds two ints", result["text"]) - assert.Equal(t, float64(42), result["duration_ms"]) -} diff --git a/internal/skills/tdd/skill.go b/internal/skills/tdd/skill.go deleted file mode 100644 index 5d99caf..0000000 --- a/internal/skills/tdd/skill.go +++ /dev/null @@ -1,86 +0,0 @@ -package tdd - -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) - -type Config struct { - SkillPrompt string - CompleteFunc CompleteFunc // nil = no executor (tests that don't reach execute()) - DefaultModel string - SessionsDir string // optional: path to brain/sessions/ for history injection - IngestBaseURL string // optional: base URL of ingestion server for brain context -} - -type Skill struct { - cfg Config -} - -func New(cfg Config) *Skill { - return &Skill{cfg: cfg} -} - -func (s *Skill) Name() string { return "tdd" } - -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 - } - strProp := map[string]any{"type": "string"} - - return []registry.ToolDef{ - { - Name: "tdd_red", - Description: "Consult a local model for help writing a failing test for the described behavior.", - InputSchema: schema( - []string{"project_root", "spec"}, - map[string]any{ - "project_root": strProp, - "spec": strProp, - "model": strProp, - "test_cmd": strProp, - }, - ), - }, - { - Name: "tdd_green", - Description: "Consult a local model for implementation ideas to make the test at test_path pass.", - InputSchema: schema( - []string{"project_root", "test_path"}, - map[string]any{ - "project_root": strProp, - "test_path": strProp, - "model": strProp, - "test_cmd": strProp, - "session_id": strProp, - }, - ), - }, - { - Name: "tdd_refactor", - Description: "Consult a local model for refactoring suggestions for impl_path while keeping tests green.", - InputSchema: schema( - []string{"project_root", "test_path", "impl_path"}, - map[string]any{ - "project_root": strProp, - "test_path": strProp, - "impl_path": strProp, - "model": strProp, - "test_cmd": strProp, - "session_id": strProp, - }, - ), - }, - } -}