package api import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os/exec" "strings" "time" ) const elaborateTimeout = 30 * time.Second // claudeJSONResult is the top-level object returned by `claude --output-format json`. type claudeJSONResult struct { Result string `json:"result"` IsError bool `json:"is_error"` } // geminiJSONResult is the top-level object returned by `gemini --output-format json`. type geminiJSONResult struct { Response string `json:"response"` } // extractJSON returns the first top-level JSON object found in s, stripping // surrounding prose or markdown code fences the model may have added. func extractJSON(s string) string { start := strings.Index(s, "{") end := strings.LastIndex(s, "}") if start == -1 || end == -1 || end < start { return s } return s[start : end+1] } func (s *Server) claudeBinaryPath() string { if s.elaborateCmdPath != "" { return s.elaborateCmdPath } if s.claudeBinPath != "" { return s.claudeBinPath } return "claude" } func (s *Server) geminiBinaryPath() string { if s.geminiBinPath != "" { return s.geminiBinPath } return "gemini" } // 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"` AcceptanceCriteria string `json:"acceptance_criteria"` 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", "acceptance_criteria": "specific, verifiable conditions a separate reviewer can check — e.g. 'run go test ./... and verify all pass; confirm GET /api/foo returns 200 with expected JSON shape'", "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 - Validation should match the scope: small change = build check; new feature = smoke test - acceptance_criteria must be concrete and verifiable by a separate agent — no vague assertions like "code looks good"` } 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) }