Adds the project_create tool to the routing pod that automates the
"new project" bootstrap end-to-end from claude.ai. Gitea-first
architecture: GitHub receives the repo only via push-mirror, never
via a direct GitHub API call from this server.
Four sequential calls to the gitea-mcp server (configured via
GITEA_MCP_URL):
1. create_project_from_template — Gitea repo from
template-go-{agent,web} per the 'stack' arg
2. repo_mirror_push (action=add) — push-mirror to
github.com/<GITHUB_OWNER>/<name>.git, interval 8h, sync_on_commit
3. file_write_branch — k3s/staging/<name>/namespace.yaml committed
on a staging/<name> branch in the infra repo
4. issue_create — experiment brief (hypothesis + description + stack
+ provisioning log) on the new repo, returns the issue_url
Returns gitea_url, github_url, issue_url, next_steps. The next_steps
string is the exact shell sequence the operator runs locally to
clone, scaffold via local-dev 'task new-project', and push.
Idempotency: create_project_from_template + repo_mirror_push +
file_write_branch all return JSON-RPC code -32003 (Conflict) when
their target already exists; the orchestrator swallows the conflict
and continues. Re-running on an existing repo restates the brief in
a fresh issue.
Error handling: on any non-conflict downstream failure the response
returns {reached: ["<step>",...], failed_step: "<step>"} alongside
a JSON-RPC error. No rollback — partial state stays so the operator
can resume manually.
New env vars (all optional except GITEA_MCP_URL):
GITEA_MCP_URL enables the tool
GITEA_MCP_TOKEN bearer auth for gitea-mcp
GITEA_OWNER default mathias
GITHUB_OWNER default mathiasb
INFRA_REPO default infra
GITHUB_PAT repo scope, used as mirror remote_password; never logged
Without GITEA_MCP_URL set, the tool is not registered and the
routing pod starts normally (degrades open).
internal/mcpclient/: new minimal JSON-RPC tools/call client with
bearer auth, used by project_create. Unwraps MCP's
content[0].text envelope and surfaces typed errors via mcpclient.Error.
Tests: table-driven against an httptest fake gitea-mcp covering happy
path (4-step success + correct PATCH-style arg shapes), idempotent
repo-exists, mirror failure (partial-success response with reached=
[create_repo] + failed_step=mirror), infra-commit failure (reached up
to mirror + failed_step=infra_commit), and validation errors.
Closes #10
160 lines
5.4 KiB
Go
160 lines
5.4 KiB
Go
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/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),
|
|
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 != "" {
|
|
reg.Register(project.New(project.Config{
|
|
Client: mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken),
|
|
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
|
|
}
|