From 6c485489bfdfbfb047e601e21599c4c31f45511d Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:16:59 +0200 Subject: [PATCH 01/20] chore: scaffold ingestion Go module --- ingestion/cmd/server/main.go | 37 ++++++++++++++++++++++++++++++++++++ ingestion/go.mod | 11 +++++++++++ ingestion/go.sum | 6 ++++++ 3 files changed, 54 insertions(+) create mode 100644 ingestion/cmd/server/main.go create mode 100644 ingestion/go.mod create mode 100644 ingestion/go.sum 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..aa256bf --- /dev/null +++ b/ingestion/go.sum @@ -0,0 +1,6 @@ +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 3c1f6edf3e70bfa4520d253b5c45b82c3da61b19 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:18:57 +0200 Subject: [PATCH 02/20] feat(ingestion): add full-text wiki search package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements search.Query which walks brainDir/wiki/**/*.md, scores files by term-frequency across query tokens, and returns results sorted by score descending. Uses only stdlib — no external search deps. Co-Authored-By: Claude Sonnet 4.6 --- ingestion/go.sum | 3 + ingestion/internal/search/search.go | 110 +++++++++++++++++++++++ ingestion/internal/search/search_test.go | 54 +++++++++++ 3 files changed, 167 insertions(+) create mode 100644 ingestion/internal/search/search.go create mode 100644 ingestion/internal/search/search_test.go diff --git a/ingestion/go.sum b/ingestion/go.sum index aa256bf..cc8b3f4 100644 --- a/ingestion/go.sum +++ b/ingestion/go.sum @@ -1,6 +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/search/search.go b/ingestion/internal/search/search.go new file mode 100644 index 0000000..764a323 --- /dev/null +++ b/ingestion/internal/search/search.go @@ -0,0 +1,110 @@ +// ingestion/internal/search/search.go +package search + +import ( + "bufio" + "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 || d.IsDir() || !strings.HasSuffix(path, ".md") { + return err + } + + content, err := os.ReadFile(path) + if err != nil { + return nil // skip unreadable files + } + + lower := strings.ToLower(string(content)) + score := 0 + for _, term := range terms { + score += strings.Count(lower, term) + } + if score == 0 { + return nil + } + + rel, _ := filepath.Rel(brainDir, path) + 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) +} From caf18c9acbb8ca23e0b08477ac3e1a66ba82fd8e Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:23:03 +0200 Subject: [PATCH 03/20] fix(ingestion): consistent error handling in search walk Both walk-level errors and ReadFile failures now use best-effort semantics (warn via slog, continue) instead of mixed abort/silent-skip. filepath.Rel error is now propagated from the callback instead of discarded. Co-Authored-By: Claude Sonnet 4.6 --- ingestion/internal/search/search.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ingestion/internal/search/search.go b/ingestion/internal/search/search.go index 764a323..5cace27 100644 --- a/ingestion/internal/search/search.go +++ b/ingestion/internal/search/search.go @@ -3,6 +3,8 @@ package search import ( "bufio" + "fmt" + "log/slog" "os" "path/filepath" "sort" @@ -32,13 +34,18 @@ func Query(brainDir, query string, limit int) ([]Result, error) { var results []Result err := filepath.WalkDir(filepath.Join(brainDir, "wiki"), func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") { - return err + 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 { - return nil // skip unreadable files + slog.Warn("search: skipping unreadable file", "path", path, "err", err) + return nil } lower := strings.ToLower(string(content)) @@ -50,7 +57,10 @@ func Query(brainDir, query string, limit int) ([]Result, error) { return nil } - rel, _ := filepath.Rel(brainDir, path) + rel, err := filepath.Rel(brainDir, path) + if err != nil { + return fmt.Errorf("rel path: %w", err) + } rel = filepath.ToSlash(rel) results = append(results, Result{ From e20edd6ca9356882d67c0c922cc134315bcd6884 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:24:51 +0200 Subject: [PATCH 04/20] feat(ingestion): add query and write HTTP handlers Implements POST /query (BM25 search via internal/search) and POST /write (raw file persistence to brain/raw/) as an api.Handler struct. Filename is auto-generated when absent. Co-Authored-By: Claude Sonnet 4.6 --- ingestion/internal/api/handler.go | 96 ++++++++++++++++++++++++++ ingestion/internal/api/handler_test.go | 83 ++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 ingestion/internal/api/handler.go create mode 100644 ingestion/internal/api/handler_test.go diff --git a/ingestion/internal/api/handler.go b/ingestion/internal/api/handler.go new file mode 100644 index 0000000..631710c --- /dev/null +++ b/ingestion/internal/api/handler.go @@ -0,0 +1,96 @@ +// ingestion/internal/api/handler.go +package api + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "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"` + Domain string `json:"domain,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type writeRequest struct { + Content string `json:"content"` + Filename string `json:"filename,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 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 + } + + dest := filepath.Join(rawDir, filepath.Base(filename)) + if err := os.WriteFile(dest, []byte(req.Content), 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..e153c0a --- /dev/null +++ b/ingestion/internal/api/handler_test.go @@ -0,0 +1,83 @@ +// 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 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")) +} From d18fa0dd5977a3aeab1e2a9fbf94250cd1b2c06a Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:27:02 +0200 Subject: [PATCH 05/20] fix(ingestion): validate required query field in Query handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty or whitespace-only queries would silently pass through to search, returning meaningless results. Also removed the Domain field from queryRequest — it was accepted but silently ignored since search.Query has no domain parameter, which would confuse callers. Co-Authored-By: Claude Sonnet 4.6 --- ingestion/internal/api/handler.go | 10 +++++++--- ingestion/internal/api/handler_test.go | 11 +++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/ingestion/internal/api/handler.go b/ingestion/internal/api/handler.go index 631710c..e3619da 100644 --- a/ingestion/internal/api/handler.go +++ b/ingestion/internal/api/handler.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/mathiasbq/hyperguild/ingestion/internal/search" @@ -25,9 +26,8 @@ func NewHandler(brainDir string, logger *slog.Logger) *Handler { } type queryRequest struct { - Query string `json:"query"` - Domain string `json:"domain,omitempty"` - Limit int `json:"limit,omitempty"` + Query string `json:"query"` + Limit int `json:"limit,omitempty"` } type writeRequest struct { @@ -42,6 +42,10 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) { 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 } diff --git a/ingestion/internal/api/handler_test.go b/ingestion/internal/api/handler_test.go index e153c0a..084cae6 100644 --- a/ingestion/internal/api/handler_test.go +++ b/ingestion/internal/api/handler_test.go @@ -68,6 +68,17 @@ func TestWrite_CreatesRawFile(t *testing.T) { 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_GeneratesFilenameIfAbsent(t *testing.T) { dir, h := setup(t) body, _ := json.Marshal(map[string]any{"content": "auto name"}) From d09f7fe7d8e42da6e682f3b323105bb52b7c6c27 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:28:29 +0200 Subject: [PATCH 06/20] feat: add tier detection package Probes Anthropic and LiteLLM endpoints to detect the current operating tier (Full / LANOnly / Airplane) so downstream code can gate model selection and managed-agent availability without manual configuration. Co-Authored-By: Claude Sonnet 4.6 --- internal/tier/tier.go | 64 ++++++++++++++++++++++++++++++++++++++ internal/tier/tier_test.go | 48 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/tier/tier.go create mode 100644 internal/tier/tier_test.go diff --git a/internal/tier/tier.go b/internal/tier/tier.go new file mode 100644 index 0000000..7758ac9 --- /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"` + 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) +} From fa6c084cf06c1f8c7b81048f811a0d5253bb381b Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:31:10 +0200 Subject: [PATCH 07/20] fix: suppress errcheck on Body.Close in tier probe --- internal/tier/tier.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tier/tier.go b/internal/tier/tier.go index 7758ac9..c4cf236 100644 --- a/internal/tier/tier.go +++ b/internal/tier/tier.go @@ -20,7 +20,7 @@ const ( type Info struct { Tier Tier `json:"tier"` Label string `json:"label"` - AvailableModels []string `json:"available_models"` + AvailableModels []string `json:"available_models"` // populated by callers as needed; Detect always returns nil ManagedAgents bool `json:"managed_agents"` } @@ -59,6 +59,6 @@ func probe(ctx context.Context, client *http.Client, url string) bool { if err != nil { return false } - resp.Body.Close() + _ = resp.Body.Close() return true } From 1b035322303fed5f35cd2c5475dbc5934d947b86 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:32:38 +0200 Subject: [PATCH 08/20] feat: add session log package (append/read JSONL) Introduces internal/session with Entry and Attempt types, Append (O_APPEND JSONL writer) and Read (line scanner, nil on missing file). Raw material for retrospective and trainer workers. Co-Authored-By: Claude Sonnet 4.6 --- internal/session/session.go | 83 ++++++++++++++++++++++++++++++++ internal/session/session_test.go | 63 ++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 internal/session/session.go create mode 100644 internal/session/session_test.go diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..c14a1b4 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,83 @@ +// internal/session/session.go +package session + +import ( + "bufio" + "encoding/json" + "fmt" + "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"` +} + +// 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) + } + defer f.Close() + + line, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("marshal entry: %w", err) + } + _, err = fmt.Fprintf(f, "%s\n", line) + return err +} + +// 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 os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("open session log: %w", err) + } + defer f.Close() + + var entries []Entry + scanner := bufio.NewScanner(f) + 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) +} From 5722532f7da918a2fb256ee5f3094c3c409a7193 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:34:36 +0200 Subject: [PATCH 09/20] fix: session log error handling and scanner buffer size - Replace deprecated os.IsNotExist with errors.Is(err, fs.ErrNotExist) - Capture Close error in Append by calling it explicitly instead of defer - Increase scanner buffer to 1 MB per line to handle large JSONL entries Co-Authored-By: Claude Sonnet 4.6 --- internal/session/session.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index c14a1b4..79337e3 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -4,7 +4,9 @@ package session import ( "bufio" "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" "time" @@ -44,21 +46,27 @@ func Append(sessionsDir, sessionID string, entry Entry) error { if err != nil { return fmt.Errorf("open session log: %w", err) } - defer f.Close() line, err := json.Marshal(entry) if err != nil { + _ = f.Close() return fmt.Errorf("marshal entry: %w", err) } - _, err = fmt.Fprintf(f, "%s\n", line) - return 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 os.IsNotExist(err) { + if errors.Is(err, fs.ErrNotExist) { return nil, nil } if err != nil { @@ -68,6 +76,7 @@ func Read(sessionsDir, sessionID string) ([]Entry, error) { 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 { From 275ba43df546e7a6b1634ad92ca2edf2858bf940 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:36:39 +0200 Subject: [PATCH 10/20] feat: add brain_query and brain_write MCP tools Adds the brain skill that proxies HTTP calls to the ingestion server, exposing brain_query (/query) and brain_write (/write) as MCP tools. Co-Authored-By: Claude Sonnet 4.6 --- internal/skills/brain/handlers.go | 90 ++++++++++++++++++++++++++ internal/skills/brain/handlers_test.go | 61 +++++++++++++++++ internal/skills/brain/skill.go | 55 ++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 internal/skills/brain/handlers.go create mode 100644 internal/skills/brain/handlers_test.go create mode 100644 internal/skills/brain/skill.go diff --git a/internal/skills/brain/handlers.go b/internal/skills/brain/handlers.go new file mode 100644 index 0000000..f92a99a --- /dev/null +++ b/internal/skills/brain/handlers.go @@ -0,0 +1,90 @@ +// 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", map[string]string{ + "content": a.Content, + "filename": a.Filename, + }) +} + +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..7d87d54 --- /dev/null +++ b/internal/skills/brain/handlers_test.go @@ -0,0 +1,61 @@ +// 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) + 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, + }), + }, + } +} From e610e253efc4bb279f223143378a17949934e621 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:38:38 +0200 Subject: [PATCH 11/20] fix(brain): pass type and domain fields to ingestion write endpoint The write handler was building a hand-rolled map that dropped the type and domain fields from writeArgs. Pass the struct directly so all fields reach the ingestion server. Strengthen the test to assert the request body contains the type field. Co-Authored-By: Claude Sonnet 4.6 --- internal/skills/brain/handlers.go | 5 +---- internal/skills/brain/handlers_test.go | 4 ++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/skills/brain/handlers.go b/internal/skills/brain/handlers.go index f92a99a..fb7584e 100644 --- a/internal/skills/brain/handlers.go +++ b/internal/skills/brain/handlers.go @@ -56,10 +56,7 @@ func (s *Skill) write(ctx context.Context, args json.RawMessage) (json.RawMessag if a.Content == "" { return nil, fmt.Errorf("content is required") } - return s.post(ctx, "/write", map[string]string{ - "content": a.Content, - "filename": a.Filename, - }) + return s.post(ctx, "/write", a) } func (s *Skill) post(ctx context.Context, path string, body any) (json.RawMessage, error) { diff --git a/internal/skills/brain/handlers_test.go b/internal/skills/brain/handlers_test.go index 7d87d54..7df6028 100644 --- a/internal/skills/brain/handlers_test.go +++ b/internal/skills/brain/handlers_test.go @@ -41,6 +41,10 @@ func TestHandle_BrainQuery_CallsIngestServer(t *testing.T) { 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() From 9cfce8f700d4970a9f5fc146610ba933db0591d4 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:40:50 +0200 Subject: [PATCH 12/20] feat: add tier and session_log MCP tools Adds two new MCP skill packages: - internal/skills/org: exposes the tier tool, calling an injected TierFn for testability; returns current operating tier as structured JSON - internal/skills/sessionlog: exposes the session_log tool, appending structured JSONL entries to brain/sessions/{session_id}.jsonl; requires session_id, wraps internal/session.Append Co-Authored-By: Claude Sonnet 4.6 --- internal/skills/org/handlers.go | 21 ++++++++ internal/skills/org/handlers_test.go | 29 +++++++++++ internal/skills/org/skill.go | 40 +++++++++++++++ internal/skills/sessionlog/handlers.go | 54 +++++++++++++++++++++ internal/skills/sessionlog/handlers_test.go | 44 +++++++++++++++++ internal/skills/sessionlog/skill.go | 49 +++++++++++++++++++ 6 files changed, 237 insertions(+) create mode 100644 internal/skills/org/handlers.go create mode 100644 internal/skills/org/handlers_test.go create mode 100644 internal/skills/org/skill.go create mode 100644 internal/skills/sessionlog/handlers.go create mode 100644 internal/skills/sessionlog/handlers_test.go create mode 100644 internal/skills/sessionlog/skill.go 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/sessionlog/handlers.go b/internal/skills/sessionlog/handlers.go new file mode 100644 index 0000000..b2fa005 --- /dev/null +++ b/internal/skills/sessionlog/handlers.go @@ -0,0 +1,54 @@ +// 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, + } + if err := session.Append(s.cfg.SessionsDir, a.SessionID, entry); err != nil { + return nil, fmt.Errorf("append session log: %w", err) + } + b, _ := json.Marshal(map[string]string{"status": "ok", "session_id": a.SessionID}) + 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"} + } + }`), + }, + } +} From a2889645fc31231e68b643de13728032b9a17dd5 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:43:01 +0200 Subject: [PATCH 13/20] fix: add Message field to session.Entry and check marshal error Co-Authored-By: Claude Sonnet 4.6 --- internal/session/session.go | 1 + internal/skills/sessionlog/handlers.go | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/session/session.go b/internal/session/session.go index 79337e3..f1d368f 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -25,6 +25,7 @@ type Entry struct { 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. diff --git a/internal/skills/sessionlog/handlers.go b/internal/skills/sessionlog/handlers.go index b2fa005..11fa392 100644 --- a/internal/skills/sessionlog/handlers.go +++ b/internal/skills/sessionlog/handlers.go @@ -45,10 +45,14 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) ( 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, _ := json.Marshal(map[string]string{"status": "ok", "session_id": a.SessionID}) + 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 } From 13ee0d7114d28ce1d07e3a13dd90467d5f1c18f5 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:45:22 +0200 Subject: [PATCH 14/20] feat: add retrospective MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds internal/skills/retrospective/ — an MCP skill that reads a session log and dispatches a worker subprocess (via ExecutorFn) to identify learnings and write them to the brain. Follows the same executor pattern as the TDD skill. Co-Authored-By: Claude Sonnet 4.6 --- internal/skills/retrospective/handlers.go | 70 +++++++++++++++++++ .../skills/retrospective/handlers_test.go | 49 +++++++++++++ internal/skills/retrospective/skill.go | 50 +++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 internal/skills/retrospective/handlers.go create mode 100644 internal/skills/retrospective/handlers_test.go create mode 100644 internal/skills/retrospective/skill.go 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"} + } + }`), + }, + } +} From 3dfc064353e7b7450e7e685f2f9586309d1d9fbf Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:48:32 +0200 Subject: [PATCH 15/20] fix: extend valid phases and return empty slice for missing session Add "retrospective" to validPhases so non-TDD skills pass Validate(). Return []Entry{} instead of nil in session.Read when no file exists, so JSON serialisation produces [] rather than null. Co-Authored-By: Claude Sonnet 4.6 --- internal/exec/result.go | 7 ++++++- internal/session/session.go | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) 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 index f1d368f..8b60055 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -68,7 +68,7 @@ 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 nil, nil + return []Entry{}, nil } if err != nil { return nil, fmt.Errorf("open session log: %w", err) From 23dd355b8a47b382b915ef7686098dab15f003ad Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:49:56 +0200 Subject: [PATCH 16/20] feat: add protocols.md, retrospective discipline, and brain directory structure --- .gitignore | 4 +++ brain/raw/.gitkeep | 0 brain/sessions/.gitkeep | 0 brain/training-data/dpo/.gitkeep | 0 brain/training-data/rl/.gitkeep | 0 brain/training-data/sft/.gitkeep | 0 brain/wiki/concepts/.gitkeep | 0 brain/wiki/entities/.gitkeep | 0 brain/wiki/sources/.gitkeep | 0 config/supervisor/protocols.md | 27 ++++++++++++++++++++ config/supervisor/retrospective.md | 40 ++++++++++++++++++++++++++++++ 11 files changed, 71 insertions(+) create mode 100644 brain/raw/.gitkeep create mode 100644 brain/sessions/.gitkeep create mode 100644 brain/training-data/dpo/.gitkeep create mode 100644 brain/training-data/rl/.gitkeep create mode 100644 brain/training-data/sft/.gitkeep create mode 100644 brain/wiki/concepts/.gitkeep create mode 100644 brain/wiki/entities/.gitkeep create mode 100644 brain/wiki/sources/.gitkeep create mode 100644 config/supervisor/protocols.md create mode 100644 config/supervisor/retrospective.md diff --git a/.gitignore b/.gitignore index 3793ab7..6e7b234 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/brain/raw/.gitkeep b/brain/raw/.gitkeep new file mode 100644 index 0000000..e69de29 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/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/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"`. From e98bb2ba65467200454f847e115071e1f47adbe6 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:52:16 +0200 Subject: [PATCH 17/20] feat: wire brain, org, sessionlog, retrospective skills into supervisor Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 9 +++++++++ cmd/supervisor/main.go | 31 +++++++++++++++++++++++++++++++ config/models.yaml | 7 ++++--- internal/config/config.go | 6 ++++++ internal/config/config_test.go | 6 ++++++ 5 files changed, 56 insertions(+), 3 deletions(-) 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/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/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) { From d084af1af0ffe5dbb872879c82701668935c00d1 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:54:40 +0200 Subject: [PATCH 18/20] chore: add ingestion server tasks and update MCP registration --- .context/mcp.json | 2 +- Taskfile.yml | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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/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 From 344def20bbbb9ebb5ec5b669444f800573c57489 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 21:18:08 +0200 Subject: [PATCH 19/20] test: phase 1 integration smoke test passing All 8 MCP tools verified (tdd_red, tdd_green, tdd_refactor, brain_query, brain_write, tier, session_log, retrospective). Ingestion write/query, brain_query, tier, and session_log all return correct responses end-to-end. Brain note written during smoke test committed to raw/ and wiki/concepts/. Co-Authored-By: Claude Sonnet 4.6 --- brain/raw/tdd-pattern-test.md | 3 +++ brain/wiki/concepts/tdd-pattern-test.md | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 brain/raw/tdd-pattern-test.md create mode 100644 brain/wiki/concepts/tdd-pattern-test.md 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/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 From 24d92164744ea909dac64f98fcdf25c7aa2587b4 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 21:22:14 +0200 Subject: [PATCH 20/20] fix(ingestion): preserve type and domain metadata as frontmatter in written notes Co-Authored-By: Claude Sonnet 4.6 --- ingestion/internal/api/handler.go | 18 +++++++++++++++++- ingestion/internal/api/handler_test.go | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/ingestion/internal/api/handler.go b/ingestion/internal/api/handler.go index e3619da..ecabac1 100644 --- a/ingestion/internal/api/handler.go +++ b/ingestion/internal/api/handler.go @@ -33,6 +33,8 @@ type queryRequest struct { 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. @@ -83,8 +85,22 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) { 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(req.Content), 0o644); err != nil { + 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 diff --git a/ingestion/internal/api/handler_test.go b/ingestion/internal/api/handler_test.go index 084cae6..d50a392 100644 --- a/ingestion/internal/api/handler_test.go +++ b/ingestion/internal/api/handler_test.go @@ -79,6 +79,27 @@ func TestQuery_RequiresQuery(t *testing.T) { 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"})