Files
hyperguild/docs/superpowers/plans/2026-04-17-hyperguild-phase1.md
Mathias Bergqvist c9310b1079
All checks were successful
cd / Build and deploy (push) Successful in 9s
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 4s
fix(ingestion): always append .md extension to written filenames
brain_write with a custom filename omitted the .md extension, causing
search to skip the file (search.go filters on HasSuffix .md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 19:23:07 +02:00

55 KiB

Hyperguild Phase 1 — Foundation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add brain access, session logging, tier detection, and a retrospective worker to the supervisor MCP server, turning it into the foundation of the hyperguild SDO.

Architecture: The supervisor repo grows two new subdirectories: ingestion/ (a separate Go HTTP server that wraps brain file I/O and text search) and brain/ (the wiki content + session logs). The supervisor MCP server gains four new tool groups — brain, org, sessionlog, retrospective — that call the ingestion server internally or append to local JSONL files. The existing TDD skill handlers are updated to automatically write session log entries after each invocation.

Tech Stack: Go 1.26, net/http (stdlib only), testify, JSONL for session logs, plain text search for brain queries (no Qdrant in Phase 1).


File Map

New — ingestion module:

  • ingestion/go.mod — separate Go module github.com/mathiasbq/hyperguild/ingestion
  • ingestion/cmd/server/main.go — HTTP server entry point (:3300)
  • ingestion/internal/api/handler.go/query and /write handlers
  • ingestion/internal/api/handler_test.go
  • ingestion/internal/search/search.go — full-text search across wiki files
  • ingestion/internal/search/search_test.go

New — supervisor packages:

  • internal/tier/tier.go — tier detection by probing endpoints
  • internal/tier/tier_test.go
  • internal/session/session.go — append/read JSONL session logs
  • internal/session/session_test.go
  • internal/skills/brain/skill.go — brain_query + brain_write MCP tools
  • internal/skills/brain/handlers.go
  • internal/skills/brain/handlers_test.go
  • internal/skills/org/skill.go — tier MCP tool
  • internal/skills/org/handlers.go
  • internal/skills/org/handlers_test.go
  • internal/skills/sessionlog/skill.go — session_log MCP tool
  • internal/skills/sessionlog/handlers.go
  • internal/skills/sessionlog/handlers_test.go
  • internal/skills/retrospective/skill.go — retrospective MCP tool
  • internal/skills/retrospective/handlers.go
  • internal/skills/retrospective/handlers_test.go

New — config files:

  • config/supervisor/protocols.md
  • config/supervisor/retrospective.md
  • brain/wiki/concepts/.gitkeep
  • brain/wiki/entities/.gitkeep
  • brain/wiki/sources/.gitkeep
  • brain/raw/.gitkeep
  • brain/sessions/.gitkeep
  • brain/training-data/sft/.gitkeep
  • brain/training-data/dpo/.gitkeep
  • brain/training-data/rl/.gitkeep

Modified:

  • internal/skills/tdd/handlers.go — call session_log after each phase
  • internal/config/config.go — add IngestBaseURL, SessionsDir, BrainDir
  • cmd/supervisor/main.go — wire new skills
  • config/models.yaml — add retrospective model
  • Taskfile.yml — add ingestion server tasks
  • .context/mcp.json — update server list
  • .env.example — add new vars

Task 1: ingestion/ module scaffold

Files:

  • Create: ingestion/go.mod

  • Create: ingestion/cmd/server/main.go

  • Step 1: Create the ingestion go.mod

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
)

Save as ingestion/go.mod. Then run:

cd ingestion && go mod tidy
  • Step 2: Create the server entry point
// 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)
	}
}
  • Step 3: Verify it compiles (handler not yet written — expect error)
cd ingestion && go build ./... 2>&1

Expected: error about missing api package. That's correct — move to Task 2.

  • Step 4: Commit scaffold
git add ingestion/
git commit -m "chore: scaffold ingestion Go module"

Task 2: ingestion search package

Files:

  • Create: ingestion/internal/search/search.go

  • Create: ingestion/internal/search/search_test.go

  • Step 1: Write the failing test

// ingestion/internal/search/search_test.go
package search_test

import (
	"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)
}

Add "fmt" import. Run:

cd ingestion && go test ./internal/search/... 2>&1

Expected: FAIL — search package does not exist.

  • Step 2: Implement search.go
// 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
}
  • Step 3: Run tests — expect PASS
cd ingestion && go test ./internal/search/... -v 2>&1

Expected: PASS (2 tests).

  • Step 4: Commit
git add ingestion/internal/search/
git commit -m "feat(ingestion): add full-text wiki search package"

Task 3: ingestion API handler

Files:

  • Create: ingestion/internal/api/handler.go

  • Create: ingestion/internal/api/handler_test.go

  • Step 1: Write the failing tests

// ingestion/internal/api/handler_test.go
package api_test

import (
	"bytes"
	"encoding/json"
	"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"
	"log/slog"
)

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"))
}

Run:

cd ingestion && go test ./internal/api/... 2>&1

Expected: FAIL — package api does not exist.

  • Step 2: Implement handler.go
// 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)
}
  • Step 3: Run tests — expect PASS
cd ingestion && go test ./internal/api/... -v 2>&1

Expected: PASS (3 tests).

  • Step 4: Verify full build
cd ingestion && go build ./... 2>&1

Expected: clean.

  • Step 5: Commit
git add ingestion/internal/
git commit -m "feat(ingestion): add query and write HTTP handlers"

Task 4: internal/tier — tier detection

Files:

  • Create: internal/tier/tier.go

  • Create: internal/tier/tier_test.go

  • Step 1: Write the failing tests

// 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)
}

Run:

go test ./internal/tier/... 2>&1

Expected: FAIL — package does not exist.

  • Step 2: Implement tier.go
// 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.
// probeTimeout is 2 seconds per probe.
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
}
  • Step 3: Run tests — expect PASS
go test ./internal/tier/... -v 2>&1

Expected: PASS (3 tests). Note: Tier2/Tier3 tests will take ~4s due to connection timeouts on port 1/2.

  • Step 4: Commit
git add internal/tier/
git commit -m "feat: add tier detection package"

Task 5: internal/session — session log

Files:

  • Create: internal/session/session.go

  • Create: internal/session/session_test.go

  • Step 1: Write the failing tests

// 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)
}

Run:

go test ./internal/session/... 2>&1

Expected: FAIL.

  • Step 2: Implement session.go
// 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()
}
  • Step 3: Run tests — expect PASS
go test ./internal/session/... -v 2>&1

Expected: PASS (3 tests).

  • Step 4: Commit
git add internal/session/
git commit -m "feat: add session log package (append/read JSONL)"

Task 6: brain skill (brain_query, brain_write MCP tools)

Files:

  • Create: internal/skills/brain/skill.go

  • Create: internal/skills/brain/handlers.go

  • Create: internal/skills/brain/handlers_test.go

  • Step 1: Write the failing tests

// 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)
}

Run:

go test ./internal/skills/brain/... 2>&1

Expected: FAIL.

  • Step 2: Implement skill.go
// internal/skills/brain/skill.go
package brain

import (
	"context"
	"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
}

// Skill implements registry.Skill for brain_query and brain_write.
type Skill struct {
	cfg Config
}

func New(cfg Config) *Skill { return &Skill{cfg: cfg} }

func (s *Skill) Name() string { return "brain" }

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,
				"domain": 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,
			}),
		},
	}
}
  • Step 3: Implement handlers.go
// internal/skills/brain/handlers.go
package brain

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

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"`
	Domain string `json:"domain,omitempty"`
	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 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
}
  • Step 4: Run tests — expect PASS
go test ./internal/skills/brain/... -v 2>&1

Expected: PASS (3 tests).

  • Step 5: Commit
git add internal/skills/brain/
git commit -m "feat: add brain_query and brain_write MCP tools"

Task 7: org skill (tier tool) + sessionlog skill (session_log tool)

Files:

  • Create: internal/skills/org/skill.go

  • Create: internal/skills/org/handlers.go

  • Create: internal/skills/org/handlers_test.go

  • Create: internal/skills/sessionlog/skill.go

  • Create: internal/skills/sessionlog/handlers.go

  • Create: internal/skills/sessionlog/handlers_test.go

  • Step 1: Write failing tests for org skill

// 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)
}

Run:

go test ./internal/skills/org/... 2>&1

Expected: FAIL.

  • Step 2: Implement org skill
// internal/skills/org/skill.go
package org

import (
	"context"
	"encoding/json"

	"github.com/mathiasbq/supervisor/internal/registry"
	"github.com/mathiasbq/supervisor/internal/tier"
)

// TierFn is a function that 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
}

func New(cfg Config) *Skill { return &Skill{cfg: cfg} }

func (s *Skill) Name() string { return "org" }

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":{}}`),
		},
	}
}
// internal/skills/org/handlers.go
package org

import (
	"context"
	"encoding/json"
	"fmt"
)

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
}
  • Step 3: Write failing tests for sessionlog skill
// 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)
}

Run:

go test ./internal/skills/sessionlog/... 2>&1

Expected: FAIL.

  • Step 4: Implement sessionlog skill
// internal/skills/sessionlog/skill.go
package sessionlog

import (
	"context"
	"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
}

func New(cfg Config) *Skill { return &Skill{cfg: cfg} }

func (s *Skill) Name() string { return "sessionlog" }

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"}
				}
			}`),
		},
	}
}
// 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"`
}

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
}
  • Step 5: Run all new tests
go test ./internal/skills/org/... ./internal/skills/sessionlog/... -v 2>&1

Expected: PASS (all tests).

  • Step 6: Commit
git add internal/skills/org/ internal/skills/sessionlog/
git commit -m "feat: add tier and session_log MCP tools"

Task 8: retrospective skill

Files:

  • Create: internal/skills/retrospective/skill.go

  • Create: internal/skills/retrospective/handlers.go

  • Create: internal/skills/retrospective/handlers_test.go

  • Step 1: Write the failing tests

// 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: "testdata",
		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")
}

Run:

go test ./internal/skills/retrospective/... 2>&1

Expected: FAIL.

  • Step 2: Implement skill.go
// 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.
type ExecutorFn func(ctx context.Context, req iexec.Request) (iexec.Result, error)

// Config holds retrospective skill configuration.
type Config struct {
	SkillPrompt  string
	DefaultModel string
	SessionsDir  string // path to brain/sessions/
	ExecutorFn   ExecutorFn
}

// Skill implements registry.Skill for the retrospective tool.
type Skill struct {
	cfg Config
}

func New(cfg Config) *Skill { return &Skill{cfg: cfg} }

func (s *Skill) Name() string { return "retrospective" }

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"}
				}
			}`),
		},
	}
}
  • Step 3: Implement handlers.go
// 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"`
}

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.
	entries, err := session.Read(s.cfg.SessionsDir, a.SessionID)
	if err != nil {
		return nil, fmt.Errorf("read session log: %w", err)
	}

	logJSON, _ := json.MarshalIndent(entries, "", "  ")
	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
}
  • Step 4: Run tests — expect PASS
go test ./internal/skills/retrospective/... -v 2>&1

Expected: PASS (2 tests).

  • Step 5: Commit
git add internal/skills/retrospective/
git commit -m "feat: add retrospective MCP tool"

Task 9: config files and brain directory structure

Files:

  • Create: config/supervisor/protocols.md

  • Create: config/supervisor/retrospective.md

  • Create: brain/ directory structure (gitkeep files)

  • Step 1: Write protocols.md

<!-- config/supervisor/protocols.md -->
# 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.
  • Step 2: Write retrospective.md
<!-- config/supervisor/retrospective.md -->
# 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".


- [ ] **Step 3: Create brain directory structure**

```bash
mkdir -p brain/wiki/concepts brain/wiki/entities brain/wiki/sources
mkdir -p brain/raw brain/sessions
mkdir -p brain/training-data/sft brain/training-data/dpo brain/training-data/rl
touch brain/wiki/concepts/.gitkeep brain/wiki/entities/.gitkeep brain/wiki/sources/.gitkeep
touch brain/raw/.gitkeep brain/sessions/.gitkeep
touch brain/training-data/sft/.gitkeep brain/training-data/dpo/.gitkeep brain/training-data/rl/.gitkeep
  • Step 4: Add brain/ to .gitignore exceptions

Edit .gitignore — add after the existing Binaries section:

# Brain content — keep wiki and structure, exclude session logs and training data
brain/sessions/*.jsonl
brain/training-data/**/*.jsonl
  • Step 5: Commit
git add config/supervisor/protocols.md config/supervisor/retrospective.md brain/
git commit -m "feat: add protocols.md, retrospective discipline, and brain directory structure"

Task 10: update config and wire new skills in main.go

Files:

  • Modify: internal/config/config.go

  • Modify: cmd/supervisor/main.go

  • Modify: config/models.yaml

  • Modify: .env.example

  • Step 1: Extend config.go

Add to the Config struct and Load() in internal/config/config.go:

// Add to Config struct:
IngestBaseURL string // INGEST_BASE_URL, default http://localhost:3300
SessionsDir   string // SUPERVISOR_SESSIONS_DIR, default ./brain/sessions
BrainDir      string // SUPERVISOR_BRAIN_DIR, default ./brain

// Add to Load():
cfg.IngestBaseURL = envOr("INGEST_BASE_URL", "http://localhost:3300")
cfg.SessionsDir   = envOr("SUPERVISOR_SESSIONS_DIR", "./brain/sessions")
cfg.BrainDir      = envOr("SUPERVISOR_BRAIN_DIR", "./brain")
  • Step 2: Update config_test.go to cover new fields

Add to TestLoad_Defaults in internal/config/config_test.go:

assert.Equal(t, "http://localhost:3300", cfg.IngestBaseURL)
assert.Equal(t, "./brain/sessions", cfg.SessionsDir)
assert.Equal(t, "./brain", cfg.BrainDir)

Run:

go test ./internal/config/... -v 2>&1

Expected: PASS.

  • Step 3: Add retrospective model to config/models.yaml
default: ollama/qwen3-coder-30b-tuned

skills:
  tdd:           ollama/qwen3-coder-30b-tuned
  review:        ollama/devstral-tuned
  debug:         ollama/deepseek-r1-tuned
  retrospective: ollama/qwen3-coder-30b-tuned
  trainer:       ollama/qwen3-coder-30b-tuned
  • Step 4: Rewrite cmd/supervisor/main.go
// cmd/supervisor/main.go
package main

import (
	"context"
	"log/slog"
	"net/http"
	"os"

	"github.com/mathiasbq/supervisor/internal/config"
	iexec "github.com/mathiasbq/supervisor/internal/exec"
	"github.com/mathiasbq/supervisor/internal/mcp"
	"github.com/mathiasbq/supervisor/internal/registry"
	"github.com/mathiasbq/supervisor/internal/skills/brain"
	"github.com/mathiasbq/supervisor/internal/skills/org"
	"github.com/mathiasbq/supervisor/internal/skills/retrospective"
	"github.com/mathiasbq/supervisor/internal/skills/sessionlog"
	"github.com/mathiasbq/supervisor/internal/skills/tdd"
	"github.com/mathiasbq/supervisor/internal/tier"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("load config", "err", err)
		os.Exit(1)
	}

	models, err := config.LoadModels(cfg.ModelsFile)
	if err != nil {
		logger.Error("load models", "err", err)
		os.Exit(1)
	}

	systemPrompt, err := os.ReadFile(cfg.ConfigDir + "/CLAUDE.md")
	if err != nil {
		logger.Error("read supervisor CLAUDE.md", "err", err)
		os.Exit(1)
	}

	tddPrompt, err := os.ReadFile(cfg.ConfigDir + "/tdd.md")
	if err != nil {
		logger.Error("read tdd.md", "err", err)
		os.Exit(1)
	}

	retroPrompt, err := os.ReadFile(cfg.ConfigDir + "/retrospective.md")
	if err != nil {
		logger.Error("read 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{
		SkillPrompt:  string(tddPrompt),
		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()
	mux.Handle("/mcp", srv)

	addr := ":" + cfg.Port
	logger.Info("supervisor starting", "addr", addr)
	if err := http.ListenAndServe(addr, mux); err != nil {
		logger.Error("server stopped", "err", err)
		os.Exit(1)
	}
}
  • Step 5: Update .env.example

Add to .env.example:

# 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
  • Step 6: Build to verify no compile errors
go build ./... 2>&1

Expected: clean.

  • Step 7: Run all tests
go test ./... 2>&1

Expected: all PASS.

  • Step 8: Commit
git add internal/config/ cmd/supervisor/main.go config/models.yaml .env.example
git commit -m "feat: wire brain, org, sessionlog, retrospective skills into supervisor"

Task 11: Taskfile and MCP registration

Files:

  • Modify: Taskfile.yml

  • Modify: .context/mcp.json

  • Step 1: Add ingestion server tasks to Taskfile.yml

Add the following tasks to Taskfile.yml:

  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

  dev:all:
    desc: Start both supervisor and ingestion server (requires two terminals)
    cmds:
      - echo "Start ingestion: task ingestion:dev"
      - echo "Start supervisor: task supervisor:dev"
  • Step 2: Update .context/mcp.json

Update .context/mcp.json to reflect the expanded tool set:

{
  "mcpServers": {
    "knowledge": {
      "url": "http://localhost:3100/mcp"
    },
    "supervisor": {
      "url": "http://localhost:3200/mcp",
      "description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
    }
  }
}
  • Step 3: Commit
git add Taskfile.yml .context/mcp.json
git commit -m "chore: add ingestion server tasks and update MCP registration"

Task 12: Integration smoke test

Verify the full Phase 1 system works end-to-end.

  • Step 1: Start the ingestion server
INGEST_BRAIN_DIR=./brain INGEST_PORT=3300 go run ./cmd/server &
sleep 1
curl -s http://localhost:3300/query -d '{"query":"test"}' -H "Content-Type: application/json" | jq .

Expected: {"results": []} (brain is empty — that's correct).

  • Step 2: Write a note to the brain
curl -s -X POST http://localhost:3300/write \
  -H "Content-Type: application/json" \
  -d '{"content": "# TDD Pattern\n\nAlways write the failing test first.", "filename": "tdd-pattern-test.md"}' | jq .

Expected: {"path": "raw/tdd-pattern-test.md"}.

  • Step 3: Start the supervisor
SUPERVISOR_CONFIG_DIR=./config/supervisor \
SUPERVISOR_MODELS_FILE=./config/models.yaml \
SUPERVISOR_SESSIONS_DIR=./brain/sessions \
SUPERVISOR_BRAIN_DIR=./brain \
INGEST_BASE_URL=http://localhost:3300 \
go run ./cmd/supervisor/ &
sleep 1
  • Step 4: Verify tools/list includes all new tools
curl -s -X POST http://localhost:3200/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq '.result.tools[].name'

Expected output includes:

"tdd_red"
"tdd_green"
"tdd_refactor"
"brain_query"
"brain_write"
"tier"
"session_log"
"retrospective"
  • Step 5: Call tier tool
curl -s -X POST http://localhost:3200/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"tier","arguments":{}}}' | jq .

Expected: valid tier response with tier, label, managed_agents fields.

  • Step 6: Call brain_query
curl -s -X POST http://localhost:3200/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"brain_query","arguments":{"query":"TDD failing test"}}}' | jq .

Expected: results array containing the tdd-pattern-test.md note written in Step 2.

  • Step 7: Call session_log
curl -s -X POST http://localhost:3200/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"session_log","arguments":{"session_id":"smoke-test","skill":"tdd_green","final_status":"pass","model_used":"test","duration_ms":1000}}}' | jq .

Expected: {"status":"ok","session_id":"smoke-test"}.

Verify file: cat brain/sessions/smoke-test.jsonl — should contain one JSON line.

  • Step 8: Stop servers and commit
pkill -f "go run ./cmd/server" 2>/dev/null
pkill -f "go run ./cmd/supervisor" 2>/dev/null
git add -A
git commit -m "test: phase 1 integration smoke test passing"

Success Criteria

  • go test ./... passes in supervisor module
  • go test ./... passes in ingestion module
  • tools/list returns 8 tools: tdd_red, tdd_green, tdd_refactor, brain_query, brain_write, tier, session_log, retrospective
  • brain_query returns results after a note is written via brain_write
  • session_log appends JSONL entries to brain/sessions/
  • tier returns a valid JSON response with tier/label/managed_agents fields
  • brain/training-data/ directory structure exists