diff --git a/cmd/floop/cmd_hook.go b/cmd/floop/cmd_hook.go index 6daac3d..9655e61 100644 --- a/cmd/floop/cmd_hook.go +++ b/cmd/floop/cmd_hook.go @@ -170,13 +170,19 @@ func formatCorrectionCapturedMessage(correctionID string) string { return fmt.Sprintf("### Correction Captured\nfloop auto-detected a correction from your message (id: %s)", correctionID) } +// floopDirExists checks whether the .floop directory exists under root. +func floopDirExists(root string) bool { + _, err := os.Stat(filepath.Join(root, ".floop")) + return !os.IsNotExist(err) +} + // hookLog appends a structured JSON log entry to .floop/hook-debug.log. // Silently no-ops if the .floop directory doesn't exist (pre-init state). func hookLog(root, hookName, stage, outcome string, extra map[string]interface{}) { - floopDir := filepath.Join(root, ".floop") - if _, err := os.Stat(floopDir); os.IsNotExist(err) { + if !floopDirExists(root) { return } + floopDir := filepath.Join(root, ".floop") logPath := filepath.Join(floopDir, "hook-debug.log") f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { @@ -274,8 +280,7 @@ func runDetectCorrection(cmd *cobra.Command, root, prompt string, client llm.Cli right := sanitize.SanitizeBehaviorContent(result.Right) // Ensure .floop exists - floopDir := filepath.Join(root, ".floop") - if _, err := os.Stat(floopDir); os.IsNotExist(err) { + if !floopDirExists(root) { fmt.Fprintf(os.Stderr, "detect-correction: floop_dir_missing (root=%s)\n", root) return nil } @@ -310,7 +315,7 @@ func runDetectCorrection(cmd *cobra.Command, root, prompt string, client llm.Cli processedAt := time.Now() correction.ProcessedAt = &processedAt - correctionsPath := filepath.Join(floopDir, "corrections.jsonl") + correctionsPath := filepath.Join(root, ".floop", "corrections.jsonl") f, err := os.OpenFile(correctionsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err == nil { json.NewEncoder(f).Encode(correction) @@ -340,8 +345,7 @@ This applies to: explicit corrections, preferences, "don't do X", repeated feedb // Used by session-start and first-prompt hooks. func runHookPrompt(cmd *cobra.Command, root string) error { // Check initialization silently - floopDir := filepath.Join(root, ".floop") - if _, err := os.Stat(floopDir); os.IsNotExist(err) { + if !floopDirExists(root) { return nil } @@ -406,8 +410,7 @@ func runHookPrompt(cmd *cobra.Command, root string) error { // for hook usage (always markdown, silent on errors). func runHookActivate(cmd *cobra.Command, root, file, task string, tokenBudget int, sessionID string) error { // Check initialization silently - floopDir := filepath.Join(root, ".floop") - if _, err := os.Stat(floopDir); os.IsNotExist(err) { + if !floopDirExists(root) { return nil } diff --git a/internal/llm/subagent.go b/internal/llm/subagent.go index 514b2d0..a2da85e 100644 --- a/internal/llm/subagent.go +++ b/internal/llm/subagent.go @@ -147,14 +147,12 @@ func (c *SubagentClient) detectAvailability() bool { inSession := c.inCLISession() if !inSession { c.logger.Debug("subagent not available: no CLI session env vars") - if c.decisions != nil { - c.decisions.Log(map[string]any{ - "event": "llm_availability", - "provider": "subagent", - "available": false, - "reason": "no CLI session env vars", - }) - } + c.logDecision(map[string]any{ + "event": "llm_availability", + "provider": "subagent", + "available": false, + "reason": "no CLI session env vars", + }) return false } @@ -162,27 +160,23 @@ func (c *SubagentClient) detectAvailability() bool { cliPath := c.findCLI() if cliPath == "" { c.logger.Debug("subagent not available: CLI not found") - if c.decisions != nil { - c.decisions.Log(map[string]any{ - "event": "llm_availability", - "provider": "subagent", - "available": false, - "reason": "CLI executable not found", - }) - } + c.logDecision(map[string]any{ + "event": "llm_availability", + "provider": "subagent", + "available": false, + "reason": "CLI executable not found", + }) return false } c.cliPath = cliPath c.logger.Debug("subagent available", "cli_path", cliPath) - if c.decisions != nil { - c.decisions.Log(map[string]any{ - "event": "llm_availability", - "provider": "subagent", - "available": true, - "cli_path": cliPath, - }) - } + c.logDecision(map[string]any{ + "event": "llm_availability", + "provider": "subagent", + "available": true, + "cli_path": cliPath, + }) return true } @@ -295,14 +289,12 @@ func (c *SubagentClient) runSubagent(ctx context.Context, prompt string) (string start := time.Now() c.logger.Debug("subagent request", "model", c.model, "prompt_len", len(prompt)) - if c.decisions != nil { - c.decisions.Log(map[string]any{ - "event": "llm_request", - "operation": "subagent", - "model": c.model, - "prompt_len": len(prompt), - }) - } + c.logDecision(map[string]any{ + "event": "llm_request", + "operation": "subagent", + "model": c.model, + "prompt_len": len(prompt), + }) // At trace level, log full prompt content c.logger.Log(ctx, logging.LevelTrace, "subagent prompt content", "prompt", prompt) @@ -334,15 +326,13 @@ func (c *SubagentClient) runSubagent(ctx context.Context, prompt string) (string if err := cmd.Run(); err != nil { duration := time.Since(start) c.logger.Debug("subagent failed", "duration_ms", duration.Milliseconds(), "error", err) - if c.decisions != nil { - c.decisions.Log(map[string]any{ - "event": "llm_response", - "operation": "subagent", - "duration_ms": duration.Milliseconds(), - "success": false, - "error": err.Error(), - }) - } + c.logDecision(map[string]any{ + "event": "llm_response", + "operation": "subagent", + "duration_ms": duration.Milliseconds(), + "success": false, + "error": err.Error(), + }) if ctx.Err() == context.DeadlineExceeded { return "", fmt.Errorf("subagent timed out after %v", c.timeout) @@ -355,28 +345,24 @@ func (c *SubagentClient) runSubagent(ctx context.Context, prompt string) (string if response == "" { c.logger.Debug("subagent empty response", "duration_ms", duration.Milliseconds()) - if c.decisions != nil { - c.decisions.Log(map[string]any{ - "event": "llm_response", - "operation": "subagent", - "duration_ms": duration.Milliseconds(), - "success": false, - "error": "empty response", - }) - } + c.logDecision(map[string]any{ + "event": "llm_response", + "operation": "subagent", + "duration_ms": duration.Milliseconds(), + "success": false, + "error": "empty response", + }) return "", fmt.Errorf("subagent returned empty response") } c.logger.Debug("subagent response", "duration_ms", duration.Milliseconds(), "response_len", len(response)) - if c.decisions != nil { - c.decisions.Log(map[string]any{ - "event": "llm_response", - "operation": "subagent", - "duration_ms": duration.Milliseconds(), - "response_len": len(response), - "success": true, - }) - } + c.logDecision(map[string]any{ + "event": "llm_response", + "operation": "subagent", + "duration_ms": duration.Milliseconds(), + "response_len": len(response), + "success": true, + }) // At trace level, log full response content c.logger.Log(ctx, logging.LevelTrace, "subagent response content", "response", response) @@ -392,6 +378,13 @@ func (c *SubagentClient) ensureLogger() { } } +// logDecision logs a decision event if the decision logger is configured. +func (c *SubagentClient) logDecision(fields map[string]any) { + if c.decisions != nil { + c.decisions.Log(fields) + } +} + // DetectAndCreate attempts to create a SubagentClient if running in a CLI session. // Returns nil if not in a CLI session or if detection fails. func DetectAndCreate() Client { diff --git a/internal/llm/subagent_test.go b/internal/llm/subagent_test.go index 6e701c9..0423f6f 100644 --- a/internal/llm/subagent_test.go +++ b/internal/llm/subagent_test.go @@ -747,6 +747,83 @@ func TestSubagentClient_DetectAvailability_LogsDecision(t *testing.T) { } } +func TestSubagentClient_DetectAvailability_CLINotFound_LogsDecision(t *testing.T) { + // Save and restore env + envVars := []string{"CLAUDE_CODE", "CLAUDE_SESSION_ID", "ANTHROPIC_CLI"} + saved := make(map[string]string) + for _, v := range envVars { + saved[v] = os.Getenv(v) + } + defer func() { + for k, v := range saved { + if v == "" { + os.Unsetenv(k) + } else { + os.Setenv(k, v) + } + } + }() + + // Clear all CLI env vars, then set one so inCLISession() returns true + for _, v := range envVars { + os.Unsetenv(v) + } + os.Setenv("CLAUDE_CODE", "1") + + dir := t.TempDir() + dl := logging.NewDecisionLogger(dir, "debug") + defer dl.Close() + + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + // Use AllowedCLIDirs pointing to an empty directory so findCLI() returns "" + emptyDir := t.TempDir() + client := NewSubagentClient(SubagentConfig{ + Logger: logger, + DecisionLogger: dl, + AllowedCLIDirs: []string{emptyDir}, + }) + + // Trigger availability detection — should hit "CLI not found" path + result := client.Available() + if result { + t.Error("expected Available() = false when no CLI in allowed dirs") + } + + // Check decision log + dl.Close() + data, err := os.ReadFile(filepath.Join(dir, "decisions.jsonl")) + if err != nil { + t.Fatalf("failed to read decisions.jsonl: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 0 { + t.Fatal("expected at least 1 decision entry, got 0") + } + + var entry map[string]any + if err := json.Unmarshal([]byte(lines[0]), &entry); err != nil { + t.Fatalf("failed to parse decision entry: %v", err) + } + + if entry["event"] != "llm_availability" { + t.Errorf("event = %v, want llm_availability", entry["event"]) + } + if entry["available"] != false { + t.Errorf("available = %v, want false", entry["available"]) + } + if entry["reason"] != "CLI executable not found" { + t.Errorf("reason = %v, want 'CLI executable not found'", entry["reason"]) + } + + // Check debug log + if !strings.Contains(buf.String(), "CLI not found") { + t.Errorf("expected debug log about CLI not found, got: %q", buf.String()) + } +} + func TestSubagentClient_RunSubagent_LogsDecision(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test uses Unix shell scripts as mock CLIs")