package exec import ( "bytes" "context" "encoding/json" "fmt" "os" "os/exec" "strings" "time" ) // Config holds executor configuration. type Config struct { ClaudeBinary string // path to claude binary, defaults to "claude" SystemPrompt string // contents of supervisor CLAUDE.md Timeout time.Duration // per-invocation timeout, default 120s LiteLLMBaseURL string // passed to Claude so it can delegate to Ollama LiteLLMAPIKey string // passed to Claude for LiteLLM auth } // Request is the input to a single supervisor invocation. type Request struct { SkillPrompt string // skill-specific discipline (e.g. tdd.md contents) TaskPrompt string // the specific task (phase, project_root, spec, model) Model string // resolved model name, passed in task prompt Tools string // comma-separated allowed tools, default "Bash,Read,Write" } // Executor spawns a claude instance and captures its structured JSON output. type Executor struct { cfg Config } func New(cfg Config) *Executor { if cfg.ClaudeBinary == "" { cfg.ClaudeBinary = "claude" } if cfg.Timeout == 0 { cfg.Timeout = 120 * time.Second } return &Executor{cfg: cfg} } func (e *Executor) Run(ctx context.Context, req Request) (Result, error) { ctx, cancel := context.WithTimeout(ctx, e.cfg.Timeout) defer cancel() tools := req.Tools if tools == "" { tools = "Bash,Read,Write" } // Build the full prompt: system rules + skill rules + infra context + task. // LITELLM_API_KEY is injected as a subprocess env var, not in the prompt, // to prevent it appearing in error log output. litellmCtx := fmt.Sprintf("LITELLM_BASE_URL: %s", e.cfg.LiteLLMBaseURL) prompt := strings.Join([]string{ e.cfg.SystemPrompt, "---", req.SkillPrompt, "---", litellmCtx, "---", req.TaskPrompt, }, "\n\n") args := []string{ "--print", "--permission-mode", "bypassPermissions", "--tools", tools, "--json-schema", Schema, "--output-format", "json", } if strings.HasPrefix(req.Model, "claude-") { args = append(args, "--model", req.Model) } args = append(args, prompt) cmd := exec.CommandContext(ctx, e.cfg.ClaudeBinary, args...) cmd.Env = append(os.Environ(), "LITELLM_API_KEY="+e.cfg.LiteLLMAPIKey) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { if ctx.Err() != nil { return Result{}, fmt.Errorf("timeout after %s", e.cfg.Timeout) } return Result{}, fmt.Errorf("claude exited with error: %w — stderr: %s", err, stderr.String()) } // --output-format json wraps the response in an envelope; structured output // from --json-schema is in the "structured_output" field. var envelope struct { StructuredOutput *Result `json:"structured_output"` IsError bool `json:"is_error"` Result string `json:"result"` // fallback text result for error messages } if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { return Result{}, fmt.Errorf("parse envelope JSON: %w — raw: %s — stderr: %s", err, stdout.String(), stderr.String()) } if envelope.StructuredOutput == nil { return Result{}, fmt.Errorf("no structured_output in response — result: %s — stderr: %s", envelope.Result, stderr.String()) } if err := envelope.StructuredOutput.Validate(); err != nil { return Result{}, fmt.Errorf("invalid result: %w", err) } return *envelope.StructuredOutput, nil }