diff --git a/.context/mcp.json b/.context/mcp.json index ab5dfb8..45b7f61 100644 --- a/.context/mcp.json +++ b/.context/mcp.json @@ -6,7 +6,7 @@ }, "supervisor": { "url": "http://localhost:3200/mcp", - "description": "Skill workers — TDD (red/green/refactor), more coming" + "description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection" } } } diff --git a/.env.example b/.env.example index d083cd0..a46d5ef 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,12 @@ SUPERVISOR_MODELS_FILE=./config/models.yaml # LiteLLM gateway (iguana) LITELLM_BASE_URL=http://iguana:4000 LITELLM_API_KEY=your-litellm-master-key + +# Ingestion server +INGEST_BASE_URL=http://localhost:3300 +INGEST_PORT=3300 +INGEST_BRAIN_DIR=./brain + +# Brain directories +SUPERVISOR_SESSIONS_DIR=./brain/sessions +SUPERVISOR_BRAIN_DIR=./brain diff --git a/.gitignore b/.gitignore index b55ec71..b61942b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ bin/ *.exe cmd/supervisor/supervisor +# Brain content — keep wiki and structure, exclude session logs and training data +brain/sessions/*.jsonl +brain/training-data/**/*.jsonl + # Go vendor/ diff --git a/Taskfile.yml b/Taskfile.yml index 04cb9bf..9bf34b9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -73,3 +73,24 @@ tasks: curl -s -X POST http://localhost:${SUPERVISOR_PORT:-3200}/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq . + + ingestion:build: + desc: Build ingestion server binary + cmds: + - go build -o bin/ingestion-server ./cmd/server + dir: ingestion + + ingestion:dev: + desc: Run ingestion server in development mode + env: + INGEST_BRAIN_DIR: "{{.ROOT_DIR}}/brain" + INGEST_PORT: "3300" + cmds: + - go run ./cmd/server + dir: ingestion + + ingestion:test: + desc: Run ingestion tests + cmds: + - go test ./... -v + dir: ingestion diff --git a/brain/raw/.gitkeep b/brain/raw/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/brain/raw/tdd-pattern-test.md b/brain/raw/tdd-pattern-test.md new file mode 100644 index 0000000..16aa5c7 --- /dev/null +++ b/brain/raw/tdd-pattern-test.md @@ -0,0 +1,3 @@ +# TDD Pattern + +Always write the failing test first. \ No newline at end of file diff --git a/brain/sessions/.gitkeep b/brain/sessions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/brain/training-data/dpo/.gitkeep b/brain/training-data/dpo/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/brain/training-data/rl/.gitkeep b/brain/training-data/rl/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/brain/training-data/sft/.gitkeep b/brain/training-data/sft/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/brain/wiki/concepts/.gitkeep b/brain/wiki/concepts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/brain/wiki/concepts/tdd-pattern-test.md b/brain/wiki/concepts/tdd-pattern-test.md new file mode 100644 index 0000000..16aa5c7 --- /dev/null +++ b/brain/wiki/concepts/tdd-pattern-test.md @@ -0,0 +1,3 @@ +# TDD Pattern + +Always write the failing test first. \ No newline at end of file diff --git a/brain/wiki/entities/.gitkeep b/brain/wiki/entities/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/brain/wiki/sources/.gitkeep b/brain/wiki/sources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/supervisor/main.go b/cmd/supervisor/main.go index d3d61c7..a57494c 100644 --- a/cmd/supervisor/main.go +++ b/cmd/supervisor/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log/slog" "net/http" "os" @@ -9,7 +10,12 @@ import ( 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" + "github.com/mathiasbq/supervisor/internal/skills/sessionlog" "github.com/mathiasbq/supervisor/internal/skills/tdd" + "github.com/mathiasbq/supervisor/internal/tier" ) func main() { @@ -39,12 +45,22 @@ func main() { 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) + } + executor := iexec.New(iexec.Config{ SystemPrompt: string(systemPrompt), LiteLLMBaseURL: cfg.LiteLLMBaseURL, LiteLLMAPIKey: cfg.LiteLLMAPIKey, }) + 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{ SystemPrompt: string(systemPrompt), @@ -52,6 +68,21 @@ func main() { DefaultModel: models.Resolve("tdd", ""), ExecutorFn: executor.Run, })) + reg.Register(brain.New(brain.Config{ + IngestBaseURL: cfg.IngestBaseURL, + })) + reg.Register(org.New(org.Config{ + TierFn: tierFn, + })) + reg.Register(sessionlog.New(sessionlog.Config{ + SessionsDir: cfg.SessionsDir, + })) + reg.Register(retrospective.New(retrospective.Config{ + SkillPrompt: string(retroPrompt), + DefaultModel: models.Resolve("retrospective", ""), + SessionsDir: cfg.SessionsDir, + ExecutorFn: executor.Run, + })) srv := mcp.NewServer(reg) mux := http.NewServeMux() diff --git a/config/models.yaml b/config/models.yaml index cc3d63c..f26fb9b 100644 --- a/config/models.yaml +++ b/config/models.yaml @@ -5,6 +5,7 @@ default: ollama/qwen3-coder-30b-tuned skills: - tdd: ollama/qwen3-coder-30b-tuned - review: ollama/devstral-tuned - debug: ollama/deepseek-r1-tuned + tdd: ollama/qwen3-coder-30b-tuned + review: ollama/devstral-tuned + debug: ollama/deepseek-r1-tuned + retrospective: ollama/qwen3-coder-30b-tuned diff --git a/config/supervisor/protocols.md b/config/supervisor/protocols.md new file mode 100644 index 0000000..500d24c --- /dev/null +++ b/config/supervisor/protocols.md @@ -0,0 +1,27 @@ +# The Hyperguild Way + +These protocols are injected into every worker invocation. They define how you behave as a member of the hyperguild. + +## Output contract + +Every response is raw JSON matching the response schema. No preamble, no prose, no markdown. Malformed output is treated as a failed invocation. + +## Quality gate + +`verified: true` only when a subprocess exit code confirms the outcome. Never self-assess. "I think the tests pass" is not verified. + +## Escalation + +If stuck after 3 attempts, return `status: error` with a clear `message` explaining why. Do not retry silently. Do not fabricate a passing result. + +## Working offline + +If brain context is absent from your prompt, proceed using your discipline file only. Note the gap in your `message` field: "no brain context available". + +## Handoff format + +Structure your output so the next worker in a chain can consume it without transformation. Use the standard result schema. Do not add extra fields. + +## Session logging + +The Go skill handler records your invocation in the session log automatically. You do not need to do this yourself. diff --git a/config/supervisor/retrospective.md b/config/supervisor/retrospective.md new file mode 100644 index 0000000..a5fbdfa --- /dev/null +++ b/config/supervisor/retrospective.md @@ -0,0 +1,40 @@ +# Retrospective Worker Discipline + +You are the retrospective worker. Your job is to review a completed coding session and identify knowledge worth preserving in the hyperguild brain. + +## What you receive + +- A session log in JSON format listing every skill invocation: what was attempted, what failed, what passed, how long it took. + +## What you produce + +For each significant learning, call brain_write with a structured markdown note. Then return a JSON result summarising what you wrote. + +## What is worth preserving + +- Patterns that worked and should be repeated +- Failures that revealed something non-obvious about the codebase or the discipline +- Decisions made during the session (architectural, structural, tooling) +- Anything that contradicts or extends what the brain already knows + +## What is NOT worth preserving + +- Routine TDD cycles with no surprises +- Single-attempt passes with no interesting context +- Mechanical operations (file moves, renames, formatting) + +## Output format + +Return JSON matching the standard result schema: + +```json +{ + "status": "pass", + "phase": "retrospective", + "skill": "retrospective", + "verified": true, + "message": "wrote N entries to brain/raw/" +} +``` + +`verified` is true when you successfully called brain_write at least once and received a confirmation. If the session had nothing worth writing, return `verified: true` with `message: "no novel learnings in this session"`. diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go new file mode 100644 index 0000000..98e7e8d --- /dev/null +++ b/ingestion/cmd/server/main.go @@ -0,0 +1,37 @@ +// ingestion/cmd/server/main.go +package main + +import ( + "log/slog" + "net/http" + "os" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + brainDir := os.Getenv("INGEST_BRAIN_DIR") + if brainDir == "" { + brainDir = "../brain" + } + + port := os.Getenv("INGEST_PORT") + if port == "" { + port = "3300" + } + + h := api.NewHandler(brainDir, logger) + + mux := http.NewServeMux() + mux.HandleFunc("/query", h.Query) + mux.HandleFunc("/write", h.Write) + + addr := ":" + port + logger.Info("ingestion server starting", "addr", addr, "brain_dir", brainDir) + if err := http.ListenAndServe(addr, mux); err != nil { + logger.Error("server stopped", "err", err) + os.Exit(1) + } +} diff --git a/ingestion/go.mod b/ingestion/go.mod new file mode 100644 index 0000000..c13d6a2 --- /dev/null +++ b/ingestion/go.mod @@ -0,0 +1,11 @@ +module github.com/mathiasbq/hyperguild/ingestion + +go 1.26.1 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/ingestion/go.sum b/ingestion/go.sum new file mode 100644 index 0000000..cc8b3f4 --- /dev/null +++ b/ingestion/go.sum @@ -0,0 +1,9 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ingestion/internal/api/handler.go b/ingestion/internal/api/handler.go new file mode 100644 index 0000000..ecabac1 --- /dev/null +++ b/ingestion/internal/api/handler.go @@ -0,0 +1,116 @@ +// ingestion/internal/api/handler.go +package api + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mathiasbq/hyperguild/ingestion/internal/search" +) + +// Handler serves the ingestion HTTP API. +type Handler struct { + brainDir string + logger *slog.Logger +} + +// NewHandler constructs a Handler. brainDir is the absolute path to brain/. +func NewHandler(brainDir string, logger *slog.Logger) *Handler { + return &Handler{brainDir: brainDir, logger: logger} +} + +type queryRequest struct { + Query string `json:"query"` + Limit int `json:"limit,omitempty"` +} + +type writeRequest struct { + Content string `json:"content"` + Filename string `json:"filename,omitempty"` + Type string `json:"type,omitempty"` + Domain string `json:"domain,omitempty"` +} + +// Query handles POST /query — full-text search across the brain wiki. +func (h *Handler) Query(w http.ResponseWriter, r *http.Request) { + var req queryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Query) == "" { + http.Error(w, "query is required", http.StatusBadRequest) + return + } + if req.Limit == 0 { + req.Limit = 5 + } + + results, err := search.Query(h.brainDir, req.Query, req.Limit) + if err != nil { + h.logger.Error("query failed", "err", err) + http.Error(w, "search error", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{"results": results}) +} + +// Write handles POST /write — write raw content to brain/raw/. +func (h *Handler) Write(w http.ResponseWriter, r *http.Request) { + var req writeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if req.Content == "" { + http.Error(w, "content is required", http.StatusBadRequest) + return + } + + filename := req.Filename + if filename == "" { + filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405")) + } + + rawDir := filepath.Join(h.brainDir, "raw") + if err := os.MkdirAll(rawDir, 0o755); err != nil { + http.Error(w, "failed to create raw dir", http.StatusInternalServerError) + return + } + + finalContent := req.Content + if req.Type != "" || req.Domain != "" { + var fm strings.Builder + fm.WriteString("---\n") + if req.Type != "" { + fmt.Fprintf(&fm, "type: %s\n", req.Type) + } + if req.Domain != "" { + fmt.Fprintf(&fm, "domain: %s\n", req.Domain) + } + fm.WriteString("---\n") + finalContent = fm.String() + req.Content + } + + dest := filepath.Join(rawDir, filepath.Base(filename)) + if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil { + h.logger.Error("write failed", "err", err) + http.Error(w, "write error", http.StatusInternalServerError) + return + } + + rel, _ := filepath.Rel(h.brainDir, dest) + writeJSON(w, map[string]string{"path": filepath.ToSlash(rel)}) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) //nolint:errcheck +} diff --git a/ingestion/internal/api/handler_test.go b/ingestion/internal/api/handler_test.go new file mode 100644 index 0000000..d50a392 --- /dev/null +++ b/ingestion/internal/api/handler_test.go @@ -0,0 +1,115 @@ +// ingestion/internal/api/handler_test.go +package api_test + +import ( + "bytes" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setup(t *testing.T) (string, *api.Handler) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "wiki", "concepts", "tdd.md"), + []byte("---\ntitle: TDD\ndomain: software\n---\n\nTest-driven development is a discipline.\n"), + 0o644, + )) + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + return dir, api.NewHandler(dir, logger) +} + +func TestQuery_ReturnsResults(t *testing.T) { + _, h := setup(t) + body, _ := json.Marshal(map[string]any{"query": "test driven", "limit": 5}) + req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.Query(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + results := resp["results"].([]any) + assert.NotEmpty(t, results) +} + +func TestWrite_CreatesRawFile(t *testing.T) { + dir, h := setup(t) + body, _ := json.Marshal(map[string]any{ + "content": "# Test note\n\nSome content.", + "filename": "test-note.md", + }) + req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.Write(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var resp map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp["path"]) + + written := filepath.Join(dir, "raw", "test-note.md") + content, err := os.ReadFile(written) + require.NoError(t, err) + assert.Contains(t, string(content), "Some content.") +} + +func TestQuery_RequiresQuery(t *testing.T) { + _, h := setup(t) + body, _ := json.Marshal(map[string]any{"limit": 5}) + req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.Query(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) { + dir, h := setup(t) + body, _ := json.Marshal(map[string]any{ + "content": "Some learning.", + "filename": "typed-note.md", + "type": "concept", + "domain": "software", + }) + req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.Write(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + content, err := os.ReadFile(filepath.Join(dir, "raw", "typed-note.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "type: concept") + assert.Contains(t, string(content), "domain: software") + assert.Contains(t, string(content), "Some learning.") +} + +func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) { + dir, h := setup(t) + body, _ := json.Marshal(map[string]any{"content": "auto name"}) + req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.Write(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + entries, _ := os.ReadDir(filepath.Join(dir, "raw")) + assert.Len(t, entries, 1) + assert.True(t, strings.HasSuffix(entries[0].Name(), ".md")) +} diff --git a/ingestion/internal/search/search.go b/ingestion/internal/search/search.go new file mode 100644 index 0000000..5cace27 --- /dev/null +++ b/ingestion/internal/search/search.go @@ -0,0 +1,120 @@ +// ingestion/internal/search/search.go +package search + +import ( + "bufio" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" +) + +// Result is a single search hit from the brain wiki. +type Result struct { + Path string `json:"path"` + Title string `json:"title"` + Excerpt string `json:"excerpt"` + Score int `json:"score"` +} + +// Query searches all .md files under brainDir/wiki/ for pages containing +// any of the whitespace-separated terms in query. Returns up to limit results +// sorted by score descending. +func Query(brainDir, query string, limit int) ([]Result, error) { + if limit <= 0 { + limit = 5 + } + terms := strings.Fields(strings.ToLower(query)) + if len(terms) == 0 { + return nil, nil + } + + var results []Result + + err := filepath.WalkDir(filepath.Join(brainDir, "wiki"), func(path string, d os.DirEntry, err error) error { + if err != nil { + slog.Warn("search: skipping path", "path", path, "err", err) + return nil + } + if d.IsDir() || !strings.HasSuffix(path, ".md") { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + slog.Warn("search: skipping unreadable file", "path", path, "err", err) + return nil + } + + lower := strings.ToLower(string(content)) + score := 0 + for _, term := range terms { + score += strings.Count(lower, term) + } + if score == 0 { + return nil + } + + rel, err := filepath.Rel(brainDir, path) + if err != nil { + return fmt.Errorf("rel path: %w", err) + } + rel = filepath.ToSlash(rel) + + results = append(results, Result{ + Path: rel, + Title: extractTitle(string(content), d.Name()), + Excerpt: excerpt(string(content), 300), + Score: score, + }) + return nil + }) + if err != nil { + return nil, err + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Score > results[j].Score + }) + if len(results) > limit { + results = results[:limit] + } + return results, nil +} + +func extractTitle(content, filename string) string { + scanner := bufio.NewScanner(strings.NewReader(content)) + inFrontmatter := false + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "---" { + if !inFrontmatter { + inFrontmatter = true + continue + } + break + } + if inFrontmatter { + key, val, ok := strings.Cut(line, ":") + if ok && strings.TrimSpace(key) == "title" { + return strings.Trim(strings.TrimSpace(val), `"'`) + } + } + } + return strings.TrimSuffix(filename, ".md") +} + +func excerpt(content string, maxLen int) string { + // Skip frontmatter, return first maxLen chars of body. + parts := strings.SplitN(content, "---", 3) + body := content + if len(parts) == 3 { + body = strings.TrimSpace(parts[2]) + } + if len(body) > maxLen { + return body[:maxLen] + "…" + } + return body +} diff --git a/ingestion/internal/search/search_test.go b/ingestion/internal/search/search_test.go new file mode 100644 index 0000000..e8a2305 --- /dev/null +++ b/ingestion/internal/search/search_test.go @@ -0,0 +1,54 @@ +// ingestion/internal/search/search_test.go +package search_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/search" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSearch_ReturnsMatchingPages(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755)) + + // Write a concept page mentioning "retry" + require.NoError(t, os.WriteFile( + filepath.Join(dir, "wiki", "concepts", "retry-logic.md"), + []byte("---\ntitle: Retry Logic\ndomain: software\n---\n\nRetry logic handles transient failures by re-attempting operations.\n"), + 0o644, + )) + // Write an unrelated page + require.NoError(t, os.WriteFile( + filepath.Join(dir, "wiki", "concepts", "database.md"), + []byte("---\ntitle: Database\ndomain: software\n---\n\nA database stores structured data.\n"), + 0o644, + )) + + results, err := search.Query(dir, "retry transient", 5) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "wiki/concepts/retry-logic.md", results[0].Path) + assert.Equal(t, "Retry Logic", results[0].Title) + assert.Greater(t, results[0].Score, 0) + assert.Contains(t, results[0].Excerpt, "Retry") +} + +func TestSearch_RespectsLimit(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755)) + for i := 0; i < 5; i++ { + require.NoError(t, os.WriteFile( + filepath.Join(dir, "wiki", "concepts", fmt.Sprintf("page-%d.md", i)), + []byte(fmt.Sprintf("---\ntitle: Page %d\n---\n\nThis page mentions retry.\n", i)), + 0o644, + )) + } + results, err := search.Query(dir, "retry", 3) + require.NoError(t, err) + assert.LessOrEqual(t, len(results), 3) +} diff --git a/internal/config/config.go b/internal/config/config.go index d788d0a..2a7ff3f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,9 @@ type Config struct { LiteLLMAPIKey string // LITELLM_API_KEY ConfigDir string // SUPERVISOR_CONFIG_DIR, default ./config/supervisor ModelsFile string // SUPERVISOR_MODELS_FILE, default /../models.yaml + IngestBaseURL string // INGEST_BASE_URL, default http://localhost:3300 + SessionsDir string // SUPERVISOR_SESSIONS_DIR, default ./brain/sessions + BrainDir string // SUPERVISOR_BRAIN_DIR, default ./brain } func Load() (Config, error) { @@ -18,6 +21,9 @@ func Load() (Config, error) { ConfigDir: envOr("SUPERVISOR_CONFIG_DIR", "./config/supervisor"), } cfg.ModelsFile = envOr("SUPERVISOR_MODELS_FILE", cfg.ConfigDir+"/../models.yaml") + cfg.IngestBaseURL = envOr("INGEST_BASE_URL", "http://localhost:3300") + cfg.SessionsDir = envOr("SUPERVISOR_SESSIONS_DIR", "./brain/sessions") + cfg.BrainDir = envOr("SUPERVISOR_BRAIN_DIR", "./brain") return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1b9ac32..f3c7ec9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -13,12 +13,18 @@ func TestLoadDefaults(t *testing.T) { t.Setenv("LITELLM_BASE_URL", "") t.Setenv("LITELLM_API_KEY", "") t.Setenv("SUPERVISOR_CONFIG_DIR", "") + t.Setenv("INGEST_BASE_URL", "") + t.Setenv("SUPERVISOR_SESSIONS_DIR", "") + t.Setenv("SUPERVISOR_BRAIN_DIR", "") cfg, err := config.Load() require.NoError(t, err) assert.Equal(t, "3200", cfg.Port) assert.Equal(t, "http://iguana:4000", cfg.LiteLLMBaseURL) assert.Equal(t, "./config/supervisor", cfg.ConfigDir) + assert.Equal(t, "http://localhost:3300", cfg.IngestBaseURL) + assert.Equal(t, "./brain/sessions", cfg.SessionsDir) + assert.Equal(t, "./brain", cfg.BrainDir) } func TestLoadFromEnv(t *testing.T) { diff --git a/internal/exec/result.go b/internal/exec/result.go index 3dc01ee..79be573 100644 --- a/internal/exec/result.go +++ b/internal/exec/result.go @@ -20,7 +20,12 @@ type Result struct { } var validStatuses = map[string]bool{"pass": true, "fail": true, "error": true} -var validPhases = map[string]bool{"red": true, "green": true, "refactor": true} +var validPhases = map[string]bool{ + "red": true, + "green": true, + "refactor": true, + "retrospective": true, +} func (r Result) Validate() error { var errs []string diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..8b60055 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,93 @@ +// internal/session/session.go +package session + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" +) + +// Entry is one skill invocation record, appended to the session JSONL log. +type Entry struct { + SessionID string `json:"session_id"` + Timestamp time.Time `json:"timestamp"` + Skill string `json:"skill"` + Phase string `json:"phase,omitempty"` + ProjectRoot string `json:"project_root,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Attempts []Attempt `json:"attempts,omitempty"` + FinalStatus string `json:"final_status"` + FilePath string `json:"file_path,omitempty"` + ModelUsed string `json:"model_used,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Message string `json:"message,omitempty"` +} + +// Attempt represents one subprocess invocation within a skill call. +type Attempt struct { + Attempt int `json:"attempt"` + Model string `json:"model"` + OutputSummary string `json:"output_summary,omitempty"` + RunnerOutput string `json:"runner_output,omitempty"` + Verified bool `json:"verified"` +} + +// Append writes entry as a single JSON line to sessionsDir/{sessionID}.jsonl. +func Append(sessionsDir, sessionID string, entry Entry) error { + if err := os.MkdirAll(sessionsDir, 0o755); err != nil { + return fmt.Errorf("create sessions dir: %w", err) + } + path := filepath.Join(sessionsDir, sessionID+".jsonl") + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open session log: %w", err) + } + + line, err := json.Marshal(entry) + if err != nil { + _ = f.Close() + return fmt.Errorf("marshal entry: %w", err) + } + if _, err = fmt.Fprintf(f, "%s\n", line); err != nil { + _ = f.Close() + return fmt.Errorf("write entry: %w", err) + } + if err = f.Close(); err != nil { + return fmt.Errorf("close session log: %w", err) + } + return nil +} + +// Read returns all entries for sessionID. Returns empty slice if no log exists. +func Read(sessionsDir, sessionID string) ([]Entry, error) { + path := filepath.Join(sessionsDir, sessionID+".jsonl") + f, err := os.Open(path) + if errors.Is(err, fs.ErrNotExist) { + return []Entry{}, nil + } + if err != nil { + return nil, fmt.Errorf("open session log: %w", err) + } + defer f.Close() + + var entries []Entry + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 256*1024), 1<<20) // up to 1 MB per line + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var e Entry + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("parse entry: %w", err) + } + entries = append(entries, e) + } + return entries, scanner.Err() +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..279654d --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,63 @@ +// internal/session/session_test.go +package session_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/mathiasbq/supervisor/internal/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAppend_WritesJSONLEntry(t *testing.T) { + dir := t.TempDir() + entry := session.Entry{ + SessionID: "test-session-1", + Timestamp: time.Now().UTC(), + Skill: "tdd_green", + Phase: "green", + ProjectRoot: "/tmp/myproject", + FinalStatus: "pass", + ModelUsed: "ollama/qwen3", + DurationMs: 5000, + } + + require.NoError(t, session.Append(dir, "test-session-1", entry)) + + path := filepath.Join(dir, "test-session-1.jsonl") + data, err := os.ReadFile(path) + require.NoError(t, err) + + var got session.Entry + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, "test-session-1", got.SessionID) + assert.Equal(t, "tdd_green", got.Skill) + assert.Equal(t, "pass", got.FinalStatus) +} + +func TestAppend_AppendsMultipleEntries(t *testing.T) { + dir := t.TempDir() + for i := 0; i < 3; i++ { + require.NoError(t, session.Append(dir, "s1", session.Entry{ + SessionID: "s1", + Timestamp: time.Now().UTC(), + Skill: "tdd_red", + FinalStatus: "pass", + })) + } + + entries, err := session.Read(dir, "s1") + require.NoError(t, err) + assert.Len(t, entries, 3) +} + +func TestRead_EmptyWhenNoFile(t *testing.T) { + dir := t.TempDir() + entries, err := session.Read(dir, "missing") + require.NoError(t, err) + assert.Empty(t, entries) +} diff --git a/internal/skills/brain/handlers.go b/internal/skills/brain/handlers.go new file mode 100644 index 0000000..fb7584e --- /dev/null +++ b/internal/skills/brain/handlers.go @@ -0,0 +1,87 @@ +// internal/skills/brain/handlers.go +package brain + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Handle dispatches brain_query and brain_write tool calls. +func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { + switch tool { + case "brain_query": + return s.query(ctx, args) + case "brain_write": + return s.write(ctx, args) + default: + return nil, fmt.Errorf("unknown brain tool: %s", tool) + } +} + +type queryArgs struct { + Query string `json:"query"` + Limit int `json:"limit,omitempty"` +} + +func (s *Skill) query(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a queryArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if a.Query == "" { + return nil, fmt.Errorf("query is required") + } + if a.Limit == 0 { + a.Limit = 5 + } + return s.post(ctx, "/query", a) +} + +type writeArgs struct { + Content string `json:"content"` + Type string `json:"type,omitempty"` + Domain string `json:"domain,omitempty"` + Filename string `json:"filename,omitempty"` +} + +func (s *Skill) write(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a writeArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if a.Content == "" { + return nil, fmt.Errorf("content is required") + } + return s.post(ctx, "/write", a) +} + +func (s *Skill) post(ctx context.Context, path string, body any) (json.RawMessage, error) { + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.IngestBaseURL+path, bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("call ingestion server: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + out, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ingestion server returned %d: %s", resp.StatusCode, out) + } + return json.RawMessage(out), nil +} diff --git a/internal/skills/brain/handlers_test.go b/internal/skills/brain/handlers_test.go new file mode 100644 index 0000000..7df6028 --- /dev/null +++ b/internal/skills/brain/handlers_test.go @@ -0,0 +1,65 @@ +// internal/skills/brain/handlers_test.go +package brain_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mathiasbq/supervisor/internal/skills/brain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandle_BrainQuery_CallsIngestServer(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/query", r.URL.Path) + called = true + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + {"path": "wiki/concepts/tdd.md", "title": "TDD", "excerpt": "Test-driven development.", "score": 3}, + }, + }) + })) + defer srv.Close() + + s := brain.New(brain.Config{IngestBaseURL: srv.URL}) + args, _ := json.Marshal(map[string]string{"query": "test driven development"}) + out, err := s.Handle(context.Background(), "brain_query", args) + require.NoError(t, err) + assert.True(t, called) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + results := result["results"].([]any) + assert.Len(t, results, 1) +} + +func TestHandle_BrainWrite_CallsIngestServer(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/write", r.URL.Path) + var body map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "concept", body["type"]) + assert.Equal(t, "# Test\n\nSome learning.", body["content"]) + json.NewEncoder(w).Encode(map[string]string{"path": "raw/test.md"}) + })) + defer srv.Close() + + s := brain.New(brain.Config{IngestBaseURL: srv.URL}) + args, _ := json.Marshal(map[string]string{"content": "# Test\n\nSome learning.", "type": "concept"}) + out, err := s.Handle(context.Background(), "brain_write", args) + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, "raw/test.md", result["path"]) +} + +func TestHandle_UnknownTool_ReturnsError(t *testing.T) { + s := brain.New(brain.Config{IngestBaseURL: "http://localhost:3300"}) + _, err := s.Handle(context.Background(), "brain_unknown", nil) + assert.Error(t, err) +} diff --git a/internal/skills/brain/skill.go b/internal/skills/brain/skill.go new file mode 100644 index 0000000..b598e24 --- /dev/null +++ b/internal/skills/brain/skill.go @@ -0,0 +1,55 @@ +// internal/skills/brain/skill.go +package brain + +import ( + "encoding/json" + + "github.com/mathiasbq/supervisor/internal/registry" +) + +// Config holds brain skill configuration. +type Config struct { + IngestBaseURL string // base URL of the ingestion HTTP server, e.g. http://localhost:3300 +} + +// Skill implements registry.Skill for brain_query and brain_write. +type Skill struct { + cfg Config +} + +// New constructs a brain Skill. +func New(cfg Config) *Skill { return &Skill{cfg: cfg} } + +// Name returns the skill name used for routing. +func (s *Skill) Name() string { return "brain" } + +// Tools returns the MCP tool definitions for brain_query and brain_write. +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"} + num := map[string]any{"type": "integer"} + + return []registry.ToolDef{ + { + Name: "brain_query", + Description: "Search the hyperguild brain wiki for relevant knowledge. Call this before starting any significant task.", + InputSchema: schema([]string{"query"}, map[string]any{ + "query": str, + "limit": num, + }), + }, + { + Name: "brain_write", + Description: "Write a raw knowledge note to the brain for later ingestion into the wiki.", + InputSchema: schema([]string{"content"}, map[string]any{ + "content": str, + "type": str, + "domain": str, + "filename": str, + }), + }, + } +} diff --git a/internal/skills/org/handlers.go b/internal/skills/org/handlers.go new file mode 100644 index 0000000..db9d960 --- /dev/null +++ b/internal/skills/org/handlers.go @@ -0,0 +1,21 @@ +// internal/skills/org/handlers.go +package org + +import ( + "context" + "encoding/json" + "fmt" +) + +// Handle dispatches the tier tool call. +func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { + if tool != "tier" { + return nil, fmt.Errorf("unknown org tool: %s", tool) + } + info := s.cfg.TierFn(ctx) + b, err := json.Marshal(info) + if err != nil { + return nil, fmt.Errorf("marshal tier info: %w", err) + } + return b, nil +} diff --git a/internal/skills/org/handlers_test.go b/internal/skills/org/handlers_test.go new file mode 100644 index 0000000..e338cb1 --- /dev/null +++ b/internal/skills/org/handlers_test.go @@ -0,0 +1,29 @@ +// internal/skills/org/handlers_test.go +package org_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/mathiasbq/supervisor/internal/skills/org" + "github.com/mathiasbq/supervisor/internal/tier" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandle_Tier_ReturnsTierInfo(t *testing.T) { + s := org.New(org.Config{ + TierFn: func(ctx context.Context) tier.Info { + return tier.Info{Tier: tier.LANOnly, Label: "lan-only", ManagedAgents: false} + }, + }) + out, err := s.Handle(context.Background(), "tier", nil) + require.NoError(t, err) + + var info tier.Info + require.NoError(t, json.Unmarshal(out, &info)) + assert.Equal(t, tier.LANOnly, info.Tier) + assert.Equal(t, "lan-only", info.Label) + assert.False(t, info.ManagedAgents) +} diff --git a/internal/skills/org/skill.go b/internal/skills/org/skill.go new file mode 100644 index 0000000..3ee5a95 --- /dev/null +++ b/internal/skills/org/skill.go @@ -0,0 +1,40 @@ +// internal/skills/org/skill.go +package org + +import ( + "context" + "encoding/json" + + "github.com/mathiasbq/supervisor/internal/registry" + "github.com/mathiasbq/supervisor/internal/tier" +) + +// TierFn returns the current tier. Injected for testability. +type TierFn func(ctx context.Context) tier.Info + +// Config holds org skill configuration. +type Config struct { + TierFn TierFn +} + +// Skill implements registry.Skill for the tier tool. +type Skill struct { + cfg Config +} + +// New constructs an org Skill. +func New(cfg Config) *Skill { return &Skill{cfg: cfg} } + +// Name returns the skill name. +func (s *Skill) Name() string { return "org" } + +// Tools returns the MCP tool definitions. +func (s *Skill) Tools() []registry.ToolDef { + return []registry.ToolDef{ + { + Name: "tier", + Description: "Returns the current operating tier: 1=full-online (Claude+Ollama+Managed Agents), 2=lan-only (Ollama only), 3=airplane (minimal). Call at session start to know which models and capabilities are available.", + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + }, + } +} diff --git a/internal/skills/retrospective/handlers.go b/internal/skills/retrospective/handlers.go new file mode 100644 index 0000000..c1e3326 --- /dev/null +++ b/internal/skills/retrospective/handlers.go @@ -0,0 +1,70 @@ +// internal/skills/retrospective/handlers.go +package retrospective + +import ( + "context" + "encoding/json" + "fmt" + + iexec "github.com/mathiasbq/supervisor/internal/exec" + "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 + } + + // Read session log entries (empty slice if no log exists yet). + 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. Write structured entries to brain/raw/ via brain_write. Return JSON result when done.", + a.SessionID, string(logJSON), + ) + + if s.cfg.ExecutorFn == nil { + return nil, fmt.Errorf("no executor configured") + } + result, err := s.cfg.ExecutorFn(ctx, iexec.Request{ + SkillPrompt: s.cfg.SkillPrompt, + TaskPrompt: taskPrompt, + Model: model, + Tools: "Bash,Read,Write", + }) + if err != nil { + return nil, fmt.Errorf("retrospective worker: %w", err) + } + + b, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("marshal result: %w", err) + } + return b, nil +} diff --git a/internal/skills/retrospective/handlers_test.go b/internal/skills/retrospective/handlers_test.go new file mode 100644 index 0000000..f7bca54 --- /dev/null +++ b/internal/skills/retrospective/handlers_test.go @@ -0,0 +1,49 @@ +// internal/skills/retrospective/handlers_test.go +package retrospective_test + +import ( + "context" + "encoding/json" + "testing" + + iexec "github.com/mathiasbq/supervisor/internal/exec" + "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 capturedReq iexec.Request + s := retrospective.New(retrospective.Config{ + SkillPrompt: "retrospective discipline", + DefaultModel: "ollama/test", + SessionsDir: t.TempDir(), // empty dir, no session file — that's OK, session.Read returns nil + ExecutorFn: func(_ context.Context, req iexec.Request) (iexec.Result, error) { + capturedReq = req + return iexec.Result{ + Status: "pass", + Phase: "retrospective", + Skill: "retrospective", + Verified: true, + Message: "wrote 2 entries to brain", + }, 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 iexec.Result + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, "pass", result.Status) + assert.Contains(t, capturedReq.SkillPrompt, "retrospective discipline") + assert.Contains(t, capturedReq.TaskPrompt, "empty-session") +} diff --git a/internal/skills/retrospective/skill.go b/internal/skills/retrospective/skill.go new file mode 100644 index 0000000..6c8b582 --- /dev/null +++ b/internal/skills/retrospective/skill.go @@ -0,0 +1,50 @@ +// internal/skills/retrospective/skill.go +package retrospective + +import ( + "context" + "encoding/json" + + iexec "github.com/mathiasbq/supervisor/internal/exec" + "github.com/mathiasbq/supervisor/internal/registry" +) + +// ExecutorFn allows injecting a test double for the subprocess executor. +type ExecutorFn func(ctx context.Context, req iexec.Request) (iexec.Result, error) + +// Config holds retrospective skill configuration. +type Config struct { + SkillPrompt string // content of retrospective.md + DefaultModel string // model to use when not specified in args + SessionsDir string // path to brain/sessions/ + ExecutorFn ExecutorFn // injected executor +} + +// 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: "Run a retrospective on a completed session. Reads the session log, identifies novel learnings, and writes structured entries to the brain for ingestion. Call at the end of each coding session.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["session_id"], + "properties": { + "session_id": {"type": "string"}, + "model": {"type": "string"} + } + }`), + }, + } +} diff --git a/internal/skills/sessionlog/handlers.go b/internal/skills/sessionlog/handlers.go new file mode 100644 index 0000000..11fa392 --- /dev/null +++ b/internal/skills/sessionlog/handlers.go @@ -0,0 +1,58 @@ +// internal/skills/sessionlog/handlers.go +package sessionlog + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/mathiasbq/supervisor/internal/session" +) + +type logArgs struct { + SessionID string `json:"session_id"` + Skill string `json:"skill"` + Phase string `json:"phase,omitempty"` + ProjectRoot string `json:"project_root,omitempty"` + FinalStatus string `json:"final_status,omitempty"` + FilePath string `json:"file_path,omitempty"` + ModelUsed string `json:"model_used,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Message string `json:"message,omitempty"` +} + +// Handle dispatches the session_log tool call. +func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { + if tool != "session_log" { + return nil, fmt.Errorf("unknown sessionlog tool: %s", tool) + } + var a logArgs + 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") + } + + entry := session.Entry{ + SessionID: a.SessionID, + Timestamp: time.Now().UTC(), + Skill: a.Skill, + Phase: a.Phase, + ProjectRoot: a.ProjectRoot, + FinalStatus: a.FinalStatus, + FilePath: a.FilePath, + ModelUsed: a.ModelUsed, + DurationMs: a.DurationMs, + Message: a.Message, + } + if err := session.Append(s.cfg.SessionsDir, a.SessionID, entry); err != nil { + return nil, fmt.Errorf("append session log: %w", err) + } + b, err := json.Marshal(map[string]string{"status": "ok", "session_id": a.SessionID}) + if err != nil { + return nil, fmt.Errorf("marshal response: %w", err) + } + return b, nil +} diff --git a/internal/skills/sessionlog/handlers_test.go b/internal/skills/sessionlog/handlers_test.go new file mode 100644 index 0000000..d2c53cd --- /dev/null +++ b/internal/skills/sessionlog/handlers_test.go @@ -0,0 +1,44 @@ +// internal/skills/sessionlog/handlers_test.go +package sessionlog_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/mathiasbq/supervisor/internal/skills/sessionlog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandle_SessionLog_AppendsEntry(t *testing.T) { + dir := t.TempDir() + s := sessionlog.New(sessionlog.Config{SessionsDir: dir}) + + args, _ := json.Marshal(map[string]any{ + "session_id": "sess-abc", + "skill": "tdd_green", + "final_status": "pass", + "model_used": "ollama/qwen3", + "duration_ms": 3000, + }) + out, err := s.Handle(context.Background(), "session_log", args) + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, "ok", result["status"]) + + // Verify file written + data, err := os.ReadFile(filepath.Join(dir, "sess-abc.jsonl")) + require.NoError(t, err) + assert.Contains(t, string(data), "tdd_green") +} + +func TestHandle_SessionLog_RequiresSessionID(t *testing.T) { + s := sessionlog.New(sessionlog.Config{SessionsDir: t.TempDir()}) + args, _ := json.Marshal(map[string]any{"skill": "tdd_red"}) + _, err := s.Handle(context.Background(), "session_log", args) + assert.Error(t, err) +} diff --git a/internal/skills/sessionlog/skill.go b/internal/skills/sessionlog/skill.go new file mode 100644 index 0000000..4f49ba5 --- /dev/null +++ b/internal/skills/sessionlog/skill.go @@ -0,0 +1,49 @@ +// internal/skills/sessionlog/skill.go +package sessionlog + +import ( + "encoding/json" + + "github.com/mathiasbq/supervisor/internal/registry" +) + +// Config holds sessionlog skill configuration. +type Config struct { + SessionsDir string // path to brain/sessions/ +} + +// Skill implements registry.Skill for the session_log tool. +type Skill struct { + cfg Config +} + +// New constructs a sessionlog Skill. +func New(cfg Config) *Skill { return &Skill{cfg: cfg} } + +// Name returns the skill name. +func (s *Skill) Name() string { return "sessionlog" } + +// Tools returns the MCP tool definitions. +func (s *Skill) Tools() []registry.ToolDef { + return []registry.ToolDef{ + { + Name: "session_log", + Description: "Append a structured entry to the current session log. Call after each skill invocation completes to record what happened for retrospective and training data extraction.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["session_id"], + "properties": { + "session_id": {"type": "string"}, + "skill": {"type": "string"}, + "phase": {"type": "string"}, + "project_root": {"type": "string"}, + "final_status": {"type": "string"}, + "file_path": {"type": "string"}, + "model_used": {"type": "string"}, + "duration_ms": {"type": "integer"}, + "message": {"type": "string"} + } + }`), + }, + } +} diff --git a/internal/tier/tier.go b/internal/tier/tier.go new file mode 100644 index 0000000..c4cf236 --- /dev/null +++ b/internal/tier/tier.go @@ -0,0 +1,64 @@ +// internal/tier/tier.go +package tier + +import ( + "context" + "net/http" + "time" +) + +// Tier represents the current operating capability level. +type Tier int + +const ( + Full Tier = 1 // internet + Anthropic API reachable + LANOnly Tier = 2 // LiteLLM on LAN reachable, no internet + Airplane Tier = 3 // no network +) + +// Info describes the current operating tier. +type Info struct { + Tier Tier `json:"tier"` + Label string `json:"label"` + AvailableModels []string `json:"available_models"` // populated by callers as needed; Detect always returns nil + ManagedAgents bool `json:"managed_agents"` +} + +// Detect probes the Anthropic endpoint and LiteLLM and returns the current tier. +// Each probe has a 2-second timeout. +func Detect(ctx context.Context, anthropicProbe, liteLLMBaseURL string) Info { + client := &http.Client{Timeout: 2 * time.Second} + + if probe(ctx, client, anthropicProbe) { + return Info{ + Tier: Full, + Label: "full-online", + ManagedAgents: true, + } + } + if probe(ctx, client, liteLLMBaseURL) { + return Info{ + Tier: LANOnly, + Label: "lan-only", + ManagedAgents: false, + } + } + return Info{ + Tier: Airplane, + Label: "airplane", + ManagedAgents: false, + } +} + +func probe(ctx context.Context, client *http.Client, url string) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false + } + resp, err := client.Do(req) + if err != nil { + return false + } + _ = resp.Body.Close() + return true +} diff --git a/internal/tier/tier_test.go b/internal/tier/tier_test.go new file mode 100644 index 0000000..fdaed0f --- /dev/null +++ b/internal/tier/tier_test.go @@ -0,0 +1,48 @@ +// internal/tier/tier_test.go +package tier_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mathiasbq/supervisor/internal/tier" + "github.com/stretchr/testify/assert" +) + +func TestDetect_Tier1_WhenBothReachable(t *testing.T) { + anthropic := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer anthropic.Close() + + litellm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer litellm.Close() + + info := tier.Detect(context.Background(), anthropic.URL, litellm.URL) + assert.Equal(t, tier.Full, info.Tier) + assert.Equal(t, "full-online", info.Label) + assert.True(t, info.ManagedAgents) +} + +func TestDetect_Tier2_WhenOnlyLiteLLMReachable(t *testing.T) { + litellm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer litellm.Close() + + info := tier.Detect(context.Background(), "http://127.0.0.1:1", litellm.URL) + assert.Equal(t, tier.LANOnly, info.Tier) + assert.Equal(t, "lan-only", info.Label) + assert.False(t, info.ManagedAgents) +} + +func TestDetect_Tier3_WhenNeitherReachable(t *testing.T) { + info := tier.Detect(context.Background(), "http://127.0.0.1:1", "http://127.0.0.1:2") + assert.Equal(t, tier.Airplane, info.Tier) + assert.Equal(t, "airplane", info.Label) + assert.False(t, info.ManagedAgents) +}