From 7697e901d229c2b55a8f2119ecdbefbc6626cd76 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 19 Apr 2026 11:59:28 +0200 Subject: [PATCH] feat(spec): add spec writing MCP skill Adds the spec skill that generates structured implementation specs from requirements and writes them to a configurable output path in the project. Follows the same pattern as review/debug skills with session history injection. Co-Authored-By: Claude Sonnet 4.6 --- cmd/supervisor/main.go | 13 ++++ config/supervisor/spec.md | 46 +++++++++++++++ internal/skills/spec/handlers.go | 85 +++++++++++++++++++++++++++ internal/skills/spec/handlers_test.go | 61 +++++++++++++++++++ internal/skills/spec/skill.go | 56 ++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 config/supervisor/spec.md create mode 100644 internal/skills/spec/handlers.go create mode 100644 internal/skills/spec/handlers_test.go create mode 100644 internal/skills/spec/skill.go diff --git a/cmd/supervisor/main.go b/cmd/supervisor/main.go index 9a6b325..de704cf 100644 --- a/cmd/supervisor/main.go +++ b/cmd/supervisor/main.go @@ -15,6 +15,7 @@ import ( "github.com/mathiasbq/supervisor/internal/skills/retrospective" skilldebug "github.com/mathiasbq/supervisor/internal/skills/debug" "github.com/mathiasbq/supervisor/internal/skills/review" + "github.com/mathiasbq/supervisor/internal/skills/spec" "github.com/mathiasbq/supervisor/internal/skills/sessionlog" "github.com/mathiasbq/supervisor/internal/skills/tdd" "github.com/mathiasbq/supervisor/internal/tier" @@ -65,6 +66,12 @@ func main() { os.Exit(1) } + specPrompt, err := os.ReadFile(cfg.ConfigDir + "/spec.md") + if err != nil { + logger.Error("read spec.md", "path", cfg.ConfigDir+"/spec.md", "err", err) + os.Exit(1) + } + executor := iexec.New(iexec.Config{ SystemPrompt: string(systemPrompt), LiteLLMBaseURL: cfg.LiteLLMBaseURL, @@ -110,6 +117,12 @@ func main() { ExecutorFn: executor.Run, SessionsDir: cfg.SessionsDir, })) + reg.Register(spec.New(spec.Config{ + SkillPrompt: string(specPrompt), + DefaultModel: models.Resolve("spec", ""), + ExecutorFn: executor.Run, + SessionsDir: cfg.SessionsDir, + })) srv := mcp.NewServer(reg) mux := http.NewServeMux() diff --git a/config/supervisor/spec.md b/config/supervisor/spec.md new file mode 100644 index 0000000..01ae148 --- /dev/null +++ b/config/supervisor/spec.md @@ -0,0 +1,46 @@ +# Spec Writing Discipline + +You write structured implementation specs. Nothing is left ambiguous. + +## Iron laws +1. Success criteria must be measurable — "the system is fast" is banned; "p99 < 200ms under 100 RPS" is valid +2. Always include an explicit "Out of scope" section — if you don't draw the boundary, the developer will guess wrong +3. Every technical decision in the approach must have a rationale + +## Output contract +Return JSON result with: +- `status`: "pass" (spec written) or "error" (requirements too ambiguous to spec without more input) +- `phase`: "spec" +- `skill`: "spec" +- `file_path`: the output_path where the spec was written (absolute path) +- `runner_output`: "" +- `verified`: true if the file was written successfully +- `message`: "spec written: " + +## Spec structure +Write the spec as markdown to the output_path: + +```markdown +# [Feature] Spec + +## Problem statement +[What problem does this solve? For whom? Why now?] + +## Success criteria +- [ ] [Criterion 1 — measurable and verifiable] +- [ ] [Criterion 2 — measurable and verifiable] + +## Constraints +[Non-negotiable requirements the solution must satisfy] + +## Out of scope +[What we are explicitly NOT doing in this iteration] + +## Technical approach +[Architecture decisions, key components, rationale for each choice] + +## Risks +[What could go wrong, and how we'd mitigate it] +``` + +If the requirements are too vague to produce measurable success criteria, return status "error" with a message listing the specific questions that need answers. diff --git a/internal/skills/spec/handlers.go b/internal/skills/spec/handlers.go new file mode 100644 index 0000000..2c3dc2d --- /dev/null +++ b/internal/skills/spec/handlers.go @@ -0,0 +1,85 @@ +// internal/skills/spec/handlers.go +package spec + +import ( + "context" + "encoding/json" + "fmt" + + iexec "github.com/mathiasbq/supervisor/internal/exec" + "github.com/mathiasbq/supervisor/internal/session" +) + +type specArgs struct { + ProjectRoot string `json:"project_root"` + Requirements string `json:"requirements"` + OutputPath string `json:"output_path"` + 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 != "spec" { + return nil, fmt.Errorf("unknown tool: %s", tool) + } + var a specArgs + 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.Requirements == "" { + return nil, fmt.Errorf("requirements is required") + } + outputPath := a.OutputPath + if outputPath == "" { + outputPath = "docs/spec.md" + } + + model := a.Model + if model == "" { + model = s.cfg.DefaultModel + } + + task := fmt.Sprintf( + "phase: spec\nproject_root: %s\nrequirements: %s\noutput_path: %s\ncontext: %s\nmodel: %s", + a.ProjectRoot, a.Requirements, outputPath, a.Context, model, + ) + task = s.prependHistory(a.SessionID, "spec", task) + + if s.cfg.ExecutorFn == nil { + return nil, fmt.Errorf("no executor configured") + } + result, err := s.cfg.ExecutorFn(ctx, iexec.Request{ + SkillPrompt: s.cfg.SkillPrompt, + TaskPrompt: task, + Model: model, + Tools: "Read,Write", + }) + if err != nil { + return nil, err + } + b, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("marshal result: %w", err) + } + return b, nil +} + +func (s *Skill) prependHistory(sessionID, currentPhase, task string) string { + if sessionID == "" || s.cfg.SessionsDir == "" { + return task + } + entries, err := session.Read(s.cfg.SessionsDir, sessionID) + if err != nil || len(entries) == 0 { + return task + } + history := session.FormatHistory(entries, currentPhase) + if history == "" { + return task + } + return history + "\n---\n\n" + task +} diff --git a/internal/skills/spec/handlers_test.go b/internal/skills/spec/handlers_test.go new file mode 100644 index 0000000..6ccf6c4 --- /dev/null +++ b/internal/skills/spec/handlers_test.go @@ -0,0 +1,61 @@ +// internal/skills/spec/handlers_test.go +package spec_test + +import ( + "context" + "encoding/json" + "testing" + + iexec "github.com/mathiasbq/supervisor/internal/exec" + "github.com/mathiasbq/supervisor/internal/skills/spec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSpecToolRegistered(t *testing.T) { + sk := spec.New(spec.Config{SkillPrompt: "spec rules"}) + names := make([]string, 0) + for _, tool := range sk.Tools() { + names = append(names, tool.Name) + } + assert.Contains(t, names, "spec") +} + +func TestSpecRequiresProjectRoot(t *testing.T) { + sk := spec.New(spec.Config{SkillPrompt: "s"}) + _, err := sk.Handle(context.Background(), "spec", json.RawMessage(`{"requirements":"add login"}`)) + assert.ErrorContains(t, err, "project_root") +} + +func TestSpecRequiresRequirements(t *testing.T) { + sk := spec.New(spec.Config{SkillPrompt: "s"}) + _, err := sk.Handle(context.Background(), "spec", json.RawMessage(`{"project_root":"/tmp"}`)) + assert.ErrorContains(t, err, "requirements") +} + +func TestSpecCallsExecutor(t *testing.T) { + called := false + var capturedTask string + fakeFn := func(_ context.Context, req iexec.Request) (iexec.Result, error) { + called = true + capturedTask = req.TaskPrompt + return iexec.Result{ + Status: "pass", Phase: "spec", Skill: "spec", + FilePath: "/tmp/proj/docs/login-spec.md", + Verified: true, ModelUsed: "self", Message: "spec written: login feature", + }, nil + } + + sk := spec.New(spec.Config{SkillPrompt: "spec rules", ExecutorFn: fakeFn, SessionsDir: t.TempDir()}) + out, err := sk.Handle(context.Background(), "spec", json.RawMessage( + `{"project_root":"/tmp/proj","requirements":"add OAuth2 login","output_path":"docs/login-spec.md"}`, + )) + require.NoError(t, err) + assert.True(t, called) + assert.Contains(t, capturedTask, "OAuth2 login") + assert.Contains(t, capturedTask, "docs/login-spec.md") + + var result iexec.Result + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, "spec", result.Phase) +} diff --git a/internal/skills/spec/skill.go b/internal/skills/spec/skill.go new file mode 100644 index 0000000..fa52d20 --- /dev/null +++ b/internal/skills/spec/skill.go @@ -0,0 +1,56 @@ +// internal/skills/spec/skill.go +package spec + +import ( + "context" + "encoding/json" + + iexec "github.com/mathiasbq/supervisor/internal/exec" + "github.com/mathiasbq/supervisor/internal/registry" +) + +// ExecutorFn is the function signature for running a worker subprocess. +type ExecutorFn func(ctx context.Context, req iexec.Request) (iexec.Result, error) + +// Config holds dependencies for the spec skill. +type Config struct { + SkillPrompt string + DefaultModel string + ExecutorFn ExecutorFn + SessionsDir string +} + +// Skill implements the spec MCP tool. +type Skill struct{ cfg Config } + +// New creates a new spec Skill. +func New(cfg Config) *Skill { return &Skill{cfg: cfg} } + +// Name returns the skill identifier. +func (s *Skill) Name() string { return "spec" } + +// 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: "spec", + Description: "Generate a structured implementation spec from requirements. Writes the spec to output_path in the project.", + InputSchema: schema( + []string{"project_root", "requirements"}, + map[string]any{ + "project_root": str, + "requirements": str, + "output_path": str, + "context": str, + "model": str, + "session_id": str, + }, + ), + }, + } +}