summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator.local>2026-03-22 08:56:21 +0000
committerClaudomator Agent <agent@claudomator.local>2026-03-22 08:56:21 +0000
commit15a46b0e8d6fc9b986bce6b17b471c4a29cc950c (patch)
tree7e8bb8006131de8edacb3247e1411716ecd1f9d0 /internal/api
parent2e0f3aaf2566db9979ca827b9d29884be8fbeee0 (diff)
feat: Phase 5 — story elaboration endpoint, approve flow, branch creation
- POST /api/stories/elaborate: runs Claude/Gemini against project LocalPath to produce a structured story plan (name, branch_name, tasks, validation) - POST /api/stories/approve: creates story + sequentially-wired tasks/subtasks from the elaborate output and pushes the story branch to origin - createStoryBranch helper: git checkout -b + push -u origin - Tests: TestBuildStoryElaboratePrompt, TestHandleStoryApprove_WiresDepends Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/elaborate.go188
-rw-r--r--internal/api/server.go2
-rw-r--r--internal/api/stories.go123
-rw-r--r--internal/api/stories_test.go84
4 files changed, 397 insertions, 0 deletions
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go
index 2c164d3..b6bc4e5 100644
--- a/internal/api/elaborate.go
+++ b/internal/api/elaborate.go
@@ -274,6 +274,194 @@ func (s *Server) elaborateWithGemini(ctx context.Context, workDir, fullPrompt st
return &result, nil
}
+// elaboratedStorySubtask is a leaf unit within a story task.
+type elaboratedStorySubtask struct {
+ Name string `json:"name"`
+ Instructions string `json:"instructions"`
+}
+
+// elaboratedStoryTask is one independently-deployable unit in a story plan.
+type elaboratedStoryTask struct {
+ Name string `json:"name"`
+ Instructions string `json:"instructions"`
+ Subtasks []elaboratedStorySubtask `json:"subtasks"`
+}
+
+// elaboratedStoryValidation describes how to verify the story was successful.
+type elaboratedStoryValidation struct {
+ Type string `json:"type"`
+ Steps []string `json:"steps"`
+ SuccessCriteria string `json:"success_criteria"`
+}
+
+// elaboratedStory is the full implementation plan produced by story elaboration.
+type elaboratedStory struct {
+ Name string `json:"name"`
+ BranchName string `json:"branch_name"`
+ Tasks []elaboratedStoryTask `json:"tasks"`
+ Validation elaboratedStoryValidation `json:"validation"`
+}
+
+func buildStoryElaboratePrompt() string {
+ return `You are a software architect. Given a goal, analyze the codebase at /workspace and produce a structured implementation plan as JSON.
+
+Output ONLY valid JSON matching this schema:
+{
+ "name": "story name",
+ "branch_name": "story/kebab-case-name",
+ "tasks": [
+ {
+ "name": "task name",
+ "instructions": "detailed instructions including file paths and what to change",
+ "subtasks": [
+ { "name": "subtask name", "instructions": "..." }
+ ]
+ }
+ ],
+ "validation": {
+ "type": "build|test|smoke",
+ "steps": ["step1", "step2"],
+ "success_criteria": "what success looks like"
+ }
+}
+
+Rules:
+- Tasks must be independently buildable (each can be deployed alone)
+- Subtasks within a task are order-dependent and run sequentially
+- Instructions must include specific file paths, function names, and exact changes
+- Instructions must end with: git add -A && git commit -m "..." && git push origin <branch>
+- Validation should match the scope: small change = build check; new feature = smoke test`
+}
+
+func (s *Server) elaborateStoryWithClaude(ctx context.Context, workDir, goal string) (*elaboratedStory, error) {
+ cmd := exec.CommandContext(ctx, s.claudeBinaryPath(),
+ "-p", goal,
+ "--system-prompt", buildStoryElaboratePrompt(),
+ "--output-format", "json",
+ "--model", "haiku",
+ )
+ if workDir != "" {
+ cmd.Dir = workDir
+ }
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ err := cmd.Run()
+
+ output := stdout.Bytes()
+ if len(output) == 0 {
+ if err != nil {
+ return nil, fmt.Errorf("claude failed: %w (stderr: %s)", err, stderr.String())
+ }
+ return nil, fmt.Errorf("claude returned no output")
+ }
+
+ var wrapper claudeJSONResult
+ if jerr := json.Unmarshal(output, &wrapper); jerr != nil {
+ return nil, fmt.Errorf("failed to parse claude JSON wrapper: %w (output: %s)", jerr, string(output))
+ }
+ if wrapper.IsError {
+ return nil, fmt.Errorf("claude error: %s", wrapper.Result)
+ }
+
+ var result elaboratedStory
+ if jerr := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); jerr != nil {
+ return nil, fmt.Errorf("failed to parse elaborated story JSON: %w (result: %s)", jerr, wrapper.Result)
+ }
+ return &result, nil
+}
+
+func (s *Server) elaborateStoryWithGemini(ctx context.Context, workDir, goal string) (*elaboratedStory, error) {
+ combinedPrompt := fmt.Sprintf("%s\n\n%s", buildStoryElaboratePrompt(), goal)
+ cmd := exec.CommandContext(ctx, s.geminiBinaryPath(),
+ "-p", combinedPrompt,
+ "--output-format", "json",
+ "--model", "gemini-2.5-flash-lite",
+ )
+ if workDir != "" {
+ cmd.Dir = workDir
+ }
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("gemini failed: %w (stderr: %s)", err, stderr.String())
+ }
+
+ var wrapper geminiJSONResult
+ if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil {
+ return nil, fmt.Errorf("failed to parse gemini JSON wrapper: %w (output: %s)", err, stdout.String())
+ }
+
+ var result elaboratedStory
+ if err := json.Unmarshal([]byte(extractJSON(wrapper.Response)), &result); err != nil {
+ return nil, fmt.Errorf("failed to parse elaborated story JSON: %w (response: %s)", err, wrapper.Response)
+ }
+ return &result, nil
+}
+
+func (s *Server) handleElaborateStory(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Goal string `json:"goal"`
+ ProjectID string `json:"project_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if input.Goal == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "goal is required"})
+ return
+ }
+ if input.ProjectID == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id is required"})
+ return
+ }
+
+ proj, err := s.store.GetProject(input.ProjectID)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"})
+ return
+ }
+
+ // Update git refs without modifying the working tree.
+ if proj.LocalPath != "" {
+ gitCmd := exec.Command("git", "-C", proj.LocalPath, "fetch", "origin")
+ if err := gitCmd.Run(); err != nil {
+ s.logger.Warn("story elaborate: git fetch failed", "error", err, "path", proj.LocalPath)
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout)
+ defer cancel()
+
+ result, err := s.elaborateStoryWithClaude(ctx, proj.LocalPath, input.Goal)
+ if err != nil {
+ s.logger.Warn("story elaborate: claude failed, falling back to gemini", "error", err)
+ result, err = s.elaborateStoryWithGemini(ctx, proj.LocalPath, input.Goal)
+ if err != nil {
+ s.logger.Error("story elaborate: fallback gemini also failed", "error", err)
+ writeJSON(w, http.StatusBadGateway, map[string]string{
+ "error": fmt.Sprintf("elaboration failed: %v", err),
+ })
+ return
+ }
+ }
+
+ if result.Name == "" {
+ writeJSON(w, http.StatusBadGateway, map[string]string{
+ "error": "elaboration failed: missing required fields in response",
+ })
+ return
+ }
+
+ writeJSON(w, http.StatusOK, result)
+}
+
func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
if s.elaborateLimiter != nil && !s.elaborateLimiter.allow(realIP(r)) {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"})
diff --git a/internal/api/server.go b/internal/api/server.go
index 092ae3a..9e3cde4 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -138,6 +138,8 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /api/projects", s.handleCreateProject)
s.mux.HandleFunc("GET /api/projects/{id}", s.handleGetProject)
s.mux.HandleFunc("PUT /api/projects/{id}", s.handleUpdateProject)
+ s.mux.HandleFunc("POST /api/stories/elaborate", s.handleElaborateStory)
+ s.mux.HandleFunc("POST /api/stories/approve", s.handleApproveStory)
s.mux.HandleFunc("GET /api/stories", s.handleListStories)
s.mux.HandleFunc("POST /api/stories", s.handleCreateStory)
s.mux.HandleFunc("GET /api/stories/{id}", s.handleGetStory)
diff --git a/internal/api/stories.go b/internal/api/stories.go
index 4b91653..459d0db 100644
--- a/internal/api/stories.go
+++ b/internal/api/stories.go
@@ -2,13 +2,34 @@ package api
import (
"encoding/json"
+ "fmt"
"net/http"
+ "os/exec"
+ "strings"
"time"
"github.com/google/uuid"
"github.com/thepeterstone/claudomator/internal/task"
)
+// createStoryBranch creates a new git branch in localPath and pushes it to origin.
+func createStoryBranch(localPath, branchName string) error {
+ out, err := exec.Command("git", "-C", localPath, "checkout", "-b", branchName).CombinedOutput()
+ if err != nil {
+ if !strings.Contains(string(out), "already exists") {
+ return fmt.Errorf("git checkout -b: %w (output: %s)", err, string(out))
+ }
+ // Branch exists; switch to it.
+ if out2, err2 := exec.Command("git", "-C", localPath, "checkout", branchName).CombinedOutput(); err2 != nil {
+ return fmt.Errorf("git checkout: %w (output: %s)", err2, string(out2))
+ }
+ }
+ if out, err := exec.Command("git", "-C", localPath, "push", "-u", "origin", branchName).CombinedOutput(); err != nil {
+ return fmt.Errorf("git push: %w (output: %s)", err, string(out))
+ }
+ return nil
+}
+
func (s *Server) handleListStories(w http.ResponseWriter, r *http.Request) {
stories, err := s.store.ListStories()
if err != nil {
@@ -183,3 +204,105 @@ func (s *Server) handleUpdateStoryStatus(w http.ResponseWriter, r *http.Request)
}
writeJSON(w, http.StatusOK, map[string]string{"message": "story status updated", "story_id": id, "status": string(input.Status)})
}
+
+func (s *Server) handleApproveStory(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Name string `json:"name"`
+ BranchName string `json:"branch_name"`
+ ProjectID string `json:"project_id"`
+ Tasks []elaboratedStoryTask `json:"tasks"`
+ Validation elaboratedStoryValidation `json:"validation"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if input.Name == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
+ return
+ }
+
+ validationJSON, _ := json.Marshal(input.Validation)
+ now := time.Now().UTC()
+ story := &task.Story{
+ ID: uuid.New().String(),
+ Name: input.Name,
+ ProjectID: input.ProjectID,
+ BranchName: input.BranchName,
+ ValidationJSON: string(validationJSON),
+ Status: task.StoryPending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := s.store.CreateStory(story); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ taskIDs := make([]string, 0, len(input.Tasks))
+ var prevTaskID string
+ for _, tp := range input.Tasks {
+ t := &task.Task{
+ ID: uuid.New().String(),
+ Name: tp.Name,
+ StoryID: story.ID,
+ Agent: task.AgentConfig{Type: "claude", Instructions: tp.Instructions},
+ Priority: task.PriorityNormal,
+ Tags: []string{},
+ DependsOn: []string{},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ State: task.StatePending,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+ if prevTaskID != "" {
+ t.DependsOn = []string{prevTaskID}
+ }
+ if err := s.store.CreateTask(t); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ taskIDs = append(taskIDs, t.ID)
+
+ var prevSubtaskID string
+ for _, sub := range tp.Subtasks {
+ st := &task.Task{
+ ID: uuid.New().String(),
+ Name: sub.Name,
+ StoryID: story.ID,
+ ParentTaskID: t.ID,
+ Agent: task.AgentConfig{Type: "claude", Instructions: sub.Instructions},
+ Priority: task.PriorityNormal,
+ Tags: []string{},
+ DependsOn: []string{},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ State: task.StatePending,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+ if prevSubtaskID != "" {
+ st.DependsOn = []string{prevSubtaskID}
+ }
+ if err := s.store.CreateTask(st); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ prevSubtaskID = st.ID
+ }
+ prevTaskID = t.ID
+ }
+
+ // Create the story branch (non-fatal if it fails).
+ if input.BranchName != "" && input.ProjectID != "" {
+ if proj, err := s.store.GetProject(input.ProjectID); err == nil && proj.LocalPath != "" {
+ if err := createStoryBranch(proj.LocalPath, input.BranchName); err != nil {
+ s.logger.Warn("story approve: failed to create branch", "error", err, "branch", input.BranchName)
+ }
+ }
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]interface{}{
+ "story": story,
+ "task_ids": taskIDs,
+ })
+}
diff --git a/internal/api/stories_test.go b/internal/api/stories_test.go
index 8516ade..cf522e1 100644
--- a/internal/api/stories_test.go
+++ b/internal/api/stories_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"github.com/thepeterstone/claudomator/internal/task"
@@ -118,3 +119,86 @@ func TestAddTaskToStory_AutoWiresDependsOn(t *testing.T) {
t.Errorf("task3.DependsOn: want [%s], got %v", task2.ID, task3.DependsOn)
}
}
+
+func TestBuildStoryElaboratePrompt(t *testing.T) {
+ prompt := buildStoryElaboratePrompt()
+ checks := []struct {
+ label string
+ want string
+ }{
+ {"schema: name field", `"name"`},
+ {"schema: branch_name field", `"branch_name"`},
+ {"schema: tasks field", `"tasks"`},
+ {"schema: validation field", `"validation"`},
+ {"rule: git push", "git push origin"},
+ {"rule: sequential subtasks", "sequentially"},
+ {"rule: specific file paths", "file paths"},
+ }
+ for _, c := range checks {
+ if !strings.Contains(prompt, c.want) {
+ t.Errorf("%s: prompt should contain %q", c.label, c.want)
+ }
+ }
+}
+
+func TestHandleStoryApprove_WiresDepends(t *testing.T) {
+ srv, _ := testServer(t)
+
+ body := `{
+ "name": "My Story",
+ "branch_name": "story/my-story",
+ "tasks": [
+ {"name": "Task 1", "instructions": "do task 1", "subtasks": []},
+ {"name": "Task 2", "instructions": "do task 2", "subtasks": []},
+ {"name": "Task 3", "instructions": "do task 3", "subtasks": []}
+ ],
+ "validation": {"type": "build", "steps": ["go build ./..."], "success_criteria": "compiles"}
+ }`
+ req := httptest.NewRequest("POST", "/api/stories/approve", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp struct {
+ Story task.Story `json:"story"`
+ TaskIDs []string `json:"task_ids"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.TaskIDs) != 3 {
+ t.Fatalf("expected 3 task IDs, got %d", len(resp.TaskIDs))
+ }
+ if resp.Story.Name != "My Story" {
+ t.Errorf("story name: want 'My Story', got %q", resp.Story.Name)
+ }
+
+ // Verify depends_on chain via the store.
+ store := srv.store
+ task1, err := store.GetTask(resp.TaskIDs[0])
+ if err != nil {
+ t.Fatalf("GetTask[0]: %v", err)
+ }
+ task2, err := store.GetTask(resp.TaskIDs[1])
+ if err != nil {
+ t.Fatalf("GetTask[1]: %v", err)
+ }
+ task3, err := store.GetTask(resp.TaskIDs[2])
+ if err != nil {
+ t.Fatalf("GetTask[2]: %v", err)
+ }
+
+ if len(task1.DependsOn) != 0 {
+ t.Errorf("task1.DependsOn: want [], got %v", task1.DependsOn)
+ }
+ if len(task2.DependsOn) != 1 || task2.DependsOn[0] != task1.ID {
+ t.Errorf("task2.DependsOn: want [%s], got %v", task1.ID, task2.DependsOn)
+ }
+ if len(task3.DependsOn) != 1 || task3.DependsOn[0] != task2.ID {
+ t.Errorf("task3.DependsOn: want [%s], got %v", task2.ID, task3.DependsOn)
+ }
+}