From 9cfce8f700d4970a9f5fc146610ba933db0591d4 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 20:40:50 +0200 Subject: [PATCH] feat: add tier and session_log MCP tools Adds two new MCP skill packages: - internal/skills/org: exposes the tier tool, calling an injected TierFn for testability; returns current operating tier as structured JSON - internal/skills/sessionlog: exposes the session_log tool, appending structured JSONL entries to brain/sessions/{session_id}.jsonl; requires session_id, wraps internal/session.Append Co-Authored-By: Claude Sonnet 4.6 --- internal/skills/org/handlers.go | 21 ++++++++ internal/skills/org/handlers_test.go | 29 +++++++++++ internal/skills/org/skill.go | 40 +++++++++++++++ internal/skills/sessionlog/handlers.go | 54 +++++++++++++++++++++ internal/skills/sessionlog/handlers_test.go | 44 +++++++++++++++++ internal/skills/sessionlog/skill.go | 49 +++++++++++++++++++ 6 files changed, 237 insertions(+) create mode 100644 internal/skills/org/handlers.go create mode 100644 internal/skills/org/handlers_test.go create mode 100644 internal/skills/org/skill.go create mode 100644 internal/skills/sessionlog/handlers.go create mode 100644 internal/skills/sessionlog/handlers_test.go create mode 100644 internal/skills/sessionlog/skill.go diff --git a/internal/skills/org/handlers.go b/internal/skills/org/handlers.go new file mode 100644 index 0000000..db9d960 --- /dev/null +++ b/internal/skills/org/handlers.go @@ -0,0 +1,21 @@ +// internal/skills/org/handlers.go +package org + +import ( + "context" + "encoding/json" + "fmt" +) + +// Handle dispatches the tier tool call. +func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { + if tool != "tier" { + return nil, fmt.Errorf("unknown org tool: %s", tool) + } + info := s.cfg.TierFn(ctx) + b, err := json.Marshal(info) + if err != nil { + return nil, fmt.Errorf("marshal tier info: %w", err) + } + return b, nil +} diff --git a/internal/skills/org/handlers_test.go b/internal/skills/org/handlers_test.go new file mode 100644 index 0000000..e338cb1 --- /dev/null +++ b/internal/skills/org/handlers_test.go @@ -0,0 +1,29 @@ +// internal/skills/org/handlers_test.go +package org_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/mathiasbq/supervisor/internal/skills/org" + "github.com/mathiasbq/supervisor/internal/tier" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandle_Tier_ReturnsTierInfo(t *testing.T) { + s := org.New(org.Config{ + TierFn: func(ctx context.Context) tier.Info { + return tier.Info{Tier: tier.LANOnly, Label: "lan-only", ManagedAgents: false} + }, + }) + out, err := s.Handle(context.Background(), "tier", nil) + require.NoError(t, err) + + var info tier.Info + require.NoError(t, json.Unmarshal(out, &info)) + assert.Equal(t, tier.LANOnly, info.Tier) + assert.Equal(t, "lan-only", info.Label) + assert.False(t, info.ManagedAgents) +} diff --git a/internal/skills/org/skill.go b/internal/skills/org/skill.go new file mode 100644 index 0000000..3ee5a95 --- /dev/null +++ b/internal/skills/org/skill.go @@ -0,0 +1,40 @@ +// internal/skills/org/skill.go +package org + +import ( + "context" + "encoding/json" + + "github.com/mathiasbq/supervisor/internal/registry" + "github.com/mathiasbq/supervisor/internal/tier" +) + +// TierFn returns the current tier. Injected for testability. +type TierFn func(ctx context.Context) tier.Info + +// Config holds org skill configuration. +type Config struct { + TierFn TierFn +} + +// Skill implements registry.Skill for the tier tool. +type Skill struct { + cfg Config +} + +// New constructs an org Skill. +func New(cfg Config) *Skill { return &Skill{cfg: cfg} } + +// Name returns the skill name. +func (s *Skill) Name() string { return "org" } + +// Tools returns the MCP tool definitions. +func (s *Skill) Tools() []registry.ToolDef { + return []registry.ToolDef{ + { + Name: "tier", + Description: "Returns the current operating tier: 1=full-online (Claude+Ollama+Managed Agents), 2=lan-only (Ollama only), 3=airplane (minimal). Call at session start to know which models and capabilities are available.", + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + }, + } +} diff --git a/internal/skills/sessionlog/handlers.go b/internal/skills/sessionlog/handlers.go new file mode 100644 index 0000000..b2fa005 --- /dev/null +++ b/internal/skills/sessionlog/handlers.go @@ -0,0 +1,54 @@ +// internal/skills/sessionlog/handlers.go +package sessionlog + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/mathiasbq/supervisor/internal/session" +) + +type logArgs struct { + SessionID string `json:"session_id"` + Skill string `json:"skill"` + Phase string `json:"phase,omitempty"` + ProjectRoot string `json:"project_root,omitempty"` + FinalStatus string `json:"final_status,omitempty"` + FilePath string `json:"file_path,omitempty"` + ModelUsed string `json:"model_used,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Message string `json:"message,omitempty"` +} + +// Handle dispatches the session_log tool call. +func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { + if tool != "session_log" { + return nil, fmt.Errorf("unknown sessionlog tool: %s", tool) + } + var a logArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if a.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + + entry := session.Entry{ + SessionID: a.SessionID, + Timestamp: time.Now().UTC(), + Skill: a.Skill, + Phase: a.Phase, + ProjectRoot: a.ProjectRoot, + FinalStatus: a.FinalStatus, + FilePath: a.FilePath, + ModelUsed: a.ModelUsed, + DurationMs: a.DurationMs, + } + if err := session.Append(s.cfg.SessionsDir, a.SessionID, entry); err != nil { + return nil, fmt.Errorf("append session log: %w", err) + } + b, _ := json.Marshal(map[string]string{"status": "ok", "session_id": a.SessionID}) + return b, nil +} diff --git a/internal/skills/sessionlog/handlers_test.go b/internal/skills/sessionlog/handlers_test.go new file mode 100644 index 0000000..d2c53cd --- /dev/null +++ b/internal/skills/sessionlog/handlers_test.go @@ -0,0 +1,44 @@ +// internal/skills/sessionlog/handlers_test.go +package sessionlog_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/mathiasbq/supervisor/internal/skills/sessionlog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandle_SessionLog_AppendsEntry(t *testing.T) { + dir := t.TempDir() + s := sessionlog.New(sessionlog.Config{SessionsDir: dir}) + + args, _ := json.Marshal(map[string]any{ + "session_id": "sess-abc", + "skill": "tdd_green", + "final_status": "pass", + "model_used": "ollama/qwen3", + "duration_ms": 3000, + }) + out, err := s.Handle(context.Background(), "session_log", args) + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, "ok", result["status"]) + + // Verify file written + data, err := os.ReadFile(filepath.Join(dir, "sess-abc.jsonl")) + require.NoError(t, err) + assert.Contains(t, string(data), "tdd_green") +} + +func TestHandle_SessionLog_RequiresSessionID(t *testing.T) { + s := sessionlog.New(sessionlog.Config{SessionsDir: t.TempDir()}) + args, _ := json.Marshal(map[string]any{"skill": "tdd_red"}) + _, err := s.Handle(context.Background(), "session_log", args) + assert.Error(t, err) +} diff --git a/internal/skills/sessionlog/skill.go b/internal/skills/sessionlog/skill.go new file mode 100644 index 0000000..4f49ba5 --- /dev/null +++ b/internal/skills/sessionlog/skill.go @@ -0,0 +1,49 @@ +// internal/skills/sessionlog/skill.go +package sessionlog + +import ( + "encoding/json" + + "github.com/mathiasbq/supervisor/internal/registry" +) + +// Config holds sessionlog skill configuration. +type Config struct { + SessionsDir string // path to brain/sessions/ +} + +// Skill implements registry.Skill for the session_log tool. +type Skill struct { + cfg Config +} + +// New constructs a sessionlog Skill. +func New(cfg Config) *Skill { return &Skill{cfg: cfg} } + +// Name returns the skill name. +func (s *Skill) Name() string { return "sessionlog" } + +// Tools returns the MCP tool definitions. +func (s *Skill) Tools() []registry.ToolDef { + return []registry.ToolDef{ + { + Name: "session_log", + Description: "Append a structured entry to the current session log. Call after each skill invocation completes to record what happened for retrospective and training data extraction.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["session_id"], + "properties": { + "session_id": {"type": "string"}, + "skill": {"type": "string"}, + "phase": {"type": "string"}, + "project_root": {"type": "string"}, + "final_status": {"type": "string"}, + "file_path": {"type": "string"}, + "model_used": {"type": "string"}, + "duration_ms": {"type": "integer"}, + "message": {"type": "string"} + } + }`), + }, + } +}