summaryrefslogtreecommitdiff
path: root/internal/api/elaborate.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/elaborate.go')
-rw-r--r--internal/api/elaborate.go188
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"})