diff --git a/internal/attractor/engine/codergen_router.go b/internal/attractor/engine/codergen_router.go index 34412256..2c510744 100644 --- a/internal/attractor/engine/codergen_router.go +++ b/internal/attractor/engine/codergen_router.go @@ -1273,8 +1273,9 @@ func (r *CodergenRouter) runCLI(ctx context.Context, execCtx *Execution, node *m if promptMode == "stdin" { cmd.Stdin = strings.NewReader(prompt) } else { - // Avoid interactive reads if the CLI tries stdin for confirmations. - cmd.Stdin = strings.NewReader("") + // Auto-accept any interactive confirmation prompts (e.g. the + // --dangerously-skip-permissions warning in Claude CLI). + cmd.Stdin = strings.NewReader("y\n") } stdoutFile, err := os.Create(stdoutPath) if err != nil { diff --git a/internal/attractor/engine/config.go b/internal/attractor/engine/config.go index 919e70e9..31526590 100644 --- a/internal/attractor/engine/config.go +++ b/internal/attractor/engine/config.go @@ -354,7 +354,7 @@ func validateConfig(cfg *RunConfigFile) error { return fmt.Errorf("llm.providers.%s backend=cli requires builtin provider with cli contract", prov) } default: - return fmt.Errorf("invalid backend for provider %q: %q (want api|cli)", prov, pc.Backend) + return fmt.Errorf("invalid backend for provider %q: %q (want api|cli)\n hint: add backend: cli (or api) under llm.providers.%s in your run config", prov, pc.Backend, prov) } if strings.EqualFold(cfg.LLM.CLIProfile, "real") && strings.TrimSpace(pc.Executable) != "" { return fmt.Errorf("llm.providers.%s.executable is only allowed when llm.cli_profile=test_shim", prov) diff --git a/internal/attractor/engine/engine.go b/internal/attractor/engine/engine.go index 66b2bb6c..a9c1d08a 100644 --- a/internal/attractor/engine/engine.go +++ b/internal/attractor/engine/engine.go @@ -339,7 +339,11 @@ func PrepareWithOptions(dotSource []byte, opts PrepareOptions) (*model.Graph, [] var errs []string for _, d := range diags { if d.Severity == validate.SeverityError { - errs = append(errs, d.Rule+": "+d.Message) + msg := d.Rule + ": " + d.Message + if d.Fix != "" { + msg += "\n hint: " + d.Fix + } + errs = append(errs, msg) } } if len(errs) > 0 { diff --git a/internal/attractor/engine/handlers.go b/internal/attractor/engine/handlers.go index 84c4091a..f29b98e7 100644 --- a/internal/attractor/engine/handlers.go +++ b/internal/attractor/engine/handlers.go @@ -919,6 +919,9 @@ func (h *ToolHandler) Execute(ctx context.Context, execCtx *Execution, node *mod if failureReason == "" { failureReason = "tool command failed" } + if hint := worktreeNotFoundHint(stderrBytes, cmdStr, execCtx); hint != "" { + failureReason += "\n hint: " + hint + } if execCtx != nil && execCtx.Engine != nil && execCtx.Engine.CXDB != nil { if _, _, err := execCtx.Engine.CXDB.Append(ctx, "com.kilroy.attractor.ToolResult", 1, map[string]any{ "run_id": execCtx.Engine.Options.RunID, @@ -1020,6 +1023,71 @@ func looksActionableToolOutputLine(line string) bool { return false } +// worktreeNotFoundHint returns a hint when a tool command fails because a +// referenced file doesn't exist in the worktree but does exist in the source +// repo. Returns "" when no hint applies. +func worktreeNotFoundHint(stderr []byte, cmdStr string, execCtx *Execution) string { + stderrStr := strings.ToLower(string(stderr)) + if !strings.Contains(stderrStr, "not found") && !strings.Contains(stderrStr, "no such file") { + return "" + } + repoPath := "" + if execCtx != nil && execCtx.Engine != nil { + repoPath = execCtx.Engine.Options.RepoPath + } + + // Try to extract a script path from the command (first token of the command). + scriptPath := extractLeadingPath(cmdStr) + if scriptPath == "" { + return "file not found — if this script is not committed to git, run 'git add && git commit' in the source repo" + } + + worktreeDir := "" + if execCtx != nil { + worktreeDir = execCtx.WorktreeDir + } + + // Check if the file exists in the source repo but not in the worktree. + inWorktree := worktreeDir != "" && pathExists(filepath.Join(worktreeDir, scriptPath)) + inRepo := repoPath != "" && pathExists(filepath.Join(repoPath, scriptPath)) + if !inWorktree && inRepo { + return fmt.Sprintf("file %q exists in the source repo but not in the worktree — it may not be committed to git; run 'git add %s && git commit'", scriptPath, scriptPath) + } + if !inWorktree && !inRepo { + return fmt.Sprintf("file %q not found in worktree or source repo — check the path in tool_command", scriptPath) + } + return "" +} + +// extractLeadingPath pulls the first token from a shell command, stripping +// common prefixes like "bash", "sh", "./" etc. Returns "" if no plausible +// file path is found. +func extractLeadingPath(cmd string) string { + cmd = strings.TrimSpace(cmd) + // Strip leading "bash -c", "sh -c", etc. + for _, prefix := range []string{"bash -c ", "sh -c "} { + if strings.HasPrefix(cmd, prefix) { + cmd = strings.TrimSpace(cmd[len(prefix):]) + // Remove surrounding quotes from the remaining command. + if len(cmd) >= 2 && (cmd[0] == '\'' || cmd[0] == '"') && cmd[len(cmd)-1] == cmd[0] { + cmd = cmd[1 : len(cmd)-1] + } + break + } + } + // Take the first whitespace-delimited token. + fields := strings.Fields(cmd) + if len(fields) == 0 { + return "" + } + candidate := fields[0] + // Skip if it looks like a bare command (no path separator and no extension). + if !strings.Contains(candidate, "/") && !strings.Contains(candidate, ".") { + return "" + } + return candidate +} + func resolveToolShellPath() string { return resolveToolShellPathWith(goruntime.GOOS, exec.LookPath, pathExists) } diff --git a/internal/attractor/engine/provider_preflight.go b/internal/attractor/engine/provider_preflight.go index 1be7fc92..69183338 100644 --- a/internal/attractor/engine/provider_preflight.go +++ b/internal/attractor/engine/provider_preflight.go @@ -235,7 +235,7 @@ func runProviderAPIPreflight(ctx context.Context, g *model.Graph, runtimes map[s Status: preflightStatusFail, Message: "api key env is not configured", }) - return fmt.Errorf("preflight: provider %s api key env is not configured", provider) + return fmt.Errorf("preflight: provider %s api key env is not configured\n hint: set api_key_env in your run config under llm.providers.%s.api, or use backend: cli to skip API keys", provider, provider) } if strings.TrimSpace(os.Getenv(keyEnv)) == "" { report.addCheck(providerPreflightCheck{ @@ -244,7 +244,7 @@ func runProviderAPIPreflight(ctx context.Context, g *model.Graph, runtimes map[s Status: preflightStatusFail, Message: fmt.Sprintf("required api key env %s is not set", keyEnv), }) - return fmt.Errorf("preflight: provider %s missing api key env %s", provider, keyEnv) + return fmt.Errorf("preflight: provider %s missing api key env %s\n hint: export %s= or switch to backend: cli in your run config", provider, keyEnv, keyEnv) } report.addCheck(providerPreflightCheck{ Name: "provider_api_credentials", diff --git a/internal/attractor/engine/run_with_config.go b/internal/attractor/engine/run_with_config.go index 987c8360..1c6942cc 100644 --- a/internal/attractor/engine/run_with_config.go +++ b/internal/attractor/engine/run_with_config.go @@ -185,7 +185,7 @@ func bootstrapRunWithConfig(ctx context.Context, dotSource []byte, cfg *RunConfi for p := range usedProviders { rt, ok := runtimes[p] if !ok || (rt.Backend != BackendAPI && rt.Backend != BackendCLI) { - return nil, fmt.Errorf("missing llm.providers.%s.backend (Kilroy forbids implicit backend defaults)", p) + return nil, fmt.Errorf("missing llm.providers.%s.backend (Kilroy forbids implicit backend defaults)\n hint: add llm.providers.%s.backend: cli (or api) to your run config, or remove --config to use auto-detection", p, p) } } runUsesCLIProviders := false diff --git a/internal/attractor/engine/run_with_config_test.go b/internal/attractor/engine/run_with_config_test.go index 3869d693..2b0f96fa 100644 --- a/internal/attractor/engine/run_with_config_test.go +++ b/internal/attractor/engine/run_with_config_test.go @@ -36,6 +36,9 @@ digraph G { if err == nil { t.Fatalf("expected error, got nil") } + if !strings.Contains(err.Error(), "hint:") { + t.Errorf("error should contain a hint for the user, got: %s", err.Error()) + } } func TestRunWithConfig_ReportsCXDBUIURL(t *testing.T) { diff --git a/internal/attractor/engine/worktree_hint_test.go b/internal/attractor/engine/worktree_hint_test.go new file mode 100644 index 00000000..6d16b97f --- /dev/null +++ b/internal/attractor/engine/worktree_hint_test.go @@ -0,0 +1,99 @@ +// Tests for worktree file-not-found hint in ToolHandler error paths. + +package engine + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtractLeadingPath(t *testing.T) { + tests := []struct { + cmd string + want string + }{ + {"./scripts/check.sh", "./scripts/check.sh"}, + {"scripts/check.sh --flag", "scripts/check.sh"}, + {"bash -c 'scripts/check.sh'", "scripts/check.sh"}, + {"sh -c \"./run.sh arg1 arg2\"", "./run.sh"}, + {"echo hello", ""}, // bare command, no path + {"ls", ""}, // bare command + {"node app.js", ""}, // first token is bare command + {"", ""}, // empty + {" ./test.sh ", "./test.sh"}, + } + for _, tt := range tests { + got := extractLeadingPath(tt.cmd) + if got != tt.want { + t.Errorf("extractLeadingPath(%q) = %q, want %q", tt.cmd, got, tt.want) + } + } +} + +func TestWorktreeNotFoundHint_FileInRepoNotWorktree(t *testing.T) { + repoDir := t.TempDir() + worktreeDir := t.TempDir() + + // Create the file in repo but not in worktree. + scriptDir := filepath.Join(repoDir, "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(scriptDir, "check.sh"), []byte("#!/bin/bash\necho ok"), 0o755); err != nil { + t.Fatal(err) + } + + execCtx := &Execution{ + WorktreeDir: worktreeDir, + Engine: &Engine{ + Options: RunOptions{RepoPath: repoDir}, + }, + } + stderr := []byte("bash: scripts/check.sh: No such file or directory") + hint := worktreeNotFoundHint(stderr, "scripts/check.sh", execCtx) + if hint == "" { + t.Fatal("expected a hint, got empty string") + } + if want := "exists in the source repo but not in the worktree"; !strings.Contains(hint, want) { + t.Errorf("hint %q should contain %q", hint, want) + } + if want := "git add"; !strings.Contains(hint, want) { + t.Errorf("hint %q should contain %q", hint, want) + } +} + +func TestWorktreeNotFoundHint_FileInNeither(t *testing.T) { + repoDir := t.TempDir() + worktreeDir := t.TempDir() + + execCtx := &Execution{ + WorktreeDir: worktreeDir, + Engine: &Engine{ + Options: RunOptions{RepoPath: repoDir}, + }, + } + stderr := []byte("bash: scripts/missing.sh: No such file or directory") + hint := worktreeNotFoundHint(stderr, "scripts/missing.sh", execCtx) + if hint == "" { + t.Fatal("expected a hint, got empty string") + } + if want := "not found in worktree or source repo"; !strings.Contains(hint, want) { + t.Errorf("hint %q should contain %q", hint, want) + } +} + +func TestWorktreeNotFoundHint_NoNotFoundInStderr(t *testing.T) { + execCtx := &Execution{ + WorktreeDir: t.TempDir(), + Engine: &Engine{ + Options: RunOptions{RepoPath: t.TempDir()}, + }, + } + stderr := []byte("some other error") + hint := worktreeNotFoundHint(stderr, "scripts/check.sh", execCtx) + if hint != "" { + t.Errorf("expected empty hint for unrelated error, got %q", hint) + } +} diff --git a/internal/attractor/validate/validate.go b/internal/attractor/validate/validate.go index d558b19a..fb5f45a3 100644 --- a/internal/attractor/validate/validate.go +++ b/internal/attractor/validate/validate.go @@ -509,6 +509,7 @@ func lintStylesheetModelIDs(g *model.Graph, catalog *modeldb.Catalog) []Diagnost Rule: "stylesheet_unknown_model", Severity: SeverityWarning, Message: fmt.Sprintf("model_stylesheet: model %q not found in catalog for provider %q", modelID, provider), + Fix: fmt.Sprintf("check the model ID spelling; for Anthropic use dots in versions (e.g. claude-sonnet-4.6)"), }) case modeldb.ModelFoundNonCanonical: diags = append(diags, Diagnostic{ @@ -856,6 +857,7 @@ func lintLLMProviderPresent(g *model.Graph) []Diagnostic { Severity: SeverityError, Message: "codergen node missing llm_provider (Kilroy forbids provider auto-detection)", NodeID: id, + Fix: "add llm_provider in a model_stylesheet (e.g. * [llm_provider=anthropic])", }) } } diff --git a/internal/attractor/validate/validate_test.go b/internal/attractor/validate/validate_test.go index 65ae0c78..90c12204 100644 --- a/internal/attractor/validate/validate_test.go +++ b/internal/attractor/validate/validate_test.go @@ -115,6 +115,11 @@ digraph G { } diags := Validate(g) assertHasRule(t, diags, "llm_provider_required", SeverityError) + for _, d := range diags { + if d.Rule == "llm_provider_required" && d.Fix == "" { + t.Error("llm_provider_required diagnostic should include a Fix hint") + } + } } func TestValidate_ToolCommandRequired_ParallelogramWithToolCommand_NoError(t *testing.T) {