refactor(harness): icebox cmd/routing path — consolidate to single harness (#75)

Consolidate to the hyperguild-alone harness that ran the infra#170 loop-1
experiment (cmd/hyperguild + brain-mcp; routing/injection machinery off).
Verified cmd/hyperguild has zero transitive dep on internal/routing or
cmd/routing's packages (imports only internal/tier). Removed the routing
path, which is dormant from the live session (.mcp.json wires only brain-mcp)
and entangled with the survivorship-biased pass-rate router (infra#174).

Iceboxed (recoverable at tag icebox/cmd-routing-2026-07-01 / branch
icebox/cmd-routing, commit 00e5f62):
- cmd/routing/, internal/routing/
- internal/skills/{review,debug,retrospective,trainer,project}/
  (each imported solely by cmd/routing — verified)
- Dockerfile.routing
- cd.yml: routing image build + infra bump + Flux-wait/rollout-verify
  (ingestion build/deploy unchanged)

Kept: cmd/hyperguild, ingestion/, internal/tier (used by cmd/hyperguild +
skills/org), internal/skills/{brain,org,sessionlog} (per #75; already orphaned).

Regression: root module 21→14 pkgs (−7 = exactly the removed set, no other
breakage); ingestion 24/24 unchanged. See ICEBOX.md. Refs #75, infra#170, #174.

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