diff options
| author | Claudomator Agent <agent@claudomator.local> | 2026-03-22 08:56:21 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator.local> | 2026-03-22 08:56:21 +0000 |
| commit | 15a46b0e8d6fc9b986bce6b17b471c4a29cc950c (patch) | |
| tree | 7e8bb8006131de8edacb3247e1411716ecd1f9d0 /internal/api/elaborate.go | |
| parent | 2e0f3aaf2566db9979ca827b9d29884be8fbeee0 (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/elaborate.go')
| -rw-r--r-- | internal/api/elaborate.go | 188 |
1 files changed, 188 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"}) |
