diff --git a/.entire/.gitignore b/.entire/.gitignore index 0b700c83e..a3573f5b8 100644 --- a/.entire/.gitignore +++ b/.entire/.gitignore @@ -3,3 +3,7 @@ settings.local.json metadata/ current_session logs/ +wingman.lock +wingman-state.json +wingman-payload.json +REVIEW.md diff --git a/.golangci.yaml b/.golangci.yaml index 0bf4d0f02..cb95625c2 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -104,6 +104,7 @@ linters: - grpc.DialOption - github.com/entireio/cli/cmd/entire/cli/agent.Agent - github.com/entireio/cli/cmd/entire/cli/strategy.Strategy + - github.com/entireio/cli/cmd/entire/cli/summarize.Generator - github.com/go-git/go-git/v6/plumbing/storer.ReferenceIter - github.com/go-git/go-git/v6/plumbing.EncodedObject - github.com/go-git/go-git/v6/storage.Storer diff --git a/cmd/entire/cli/agent/claudecode/claude.go b/cmd/entire/cli/agent/claudecode/claude.go index b92c51801..0ac444ad9 100644 --- a/cmd/entire/cli/agent/claudecode/claude.go +++ b/cmd/entire/cli/agent/claudecode/claude.go @@ -3,11 +3,13 @@ package claudecode import ( "bufio" + "context" "encoding/json" "errors" "fmt" "io" "os" + "os/exec" "path/filepath" "regexp" "time" @@ -25,7 +27,11 @@ func init() { // ClaudeCodeAgent implements the Agent interface for Claude Code. // //nolint:revive // ClaudeCodeAgent is clearer than Agent in this context -type ClaudeCodeAgent struct{} +type ClaudeCodeAgent struct { + // CommandRunner allows injection of the command execution for testing. + // If nil, uses exec.CommandContext directly. + CommandRunner func(ctx context.Context, name string, args ...string) *exec.Cmd +} // NewClaudeCodeAgent creates a new Claude Code agent instance. func NewClaudeCodeAgent() agent.Agent { diff --git a/cmd/entire/cli/agent/claudecode/hooks.go b/cmd/entire/cli/agent/claudecode/hooks.go index f1fcf3dfc..af41934ab 100644 --- a/cmd/entire/cli/agent/claudecode/hooks.go +++ b/cmd/entire/cli/agent/claudecode/hooks.go @@ -109,14 +109,15 @@ func (c *ClaudeCodeAgent) InstallHooks(localDev bool, force bool) (int, error) { rawPermissions = make(map[string]json.RawMessage) } - // Parse only the hook types we need to modify + // Parse only the hook types we need to modify. + // Track which types parsed successfully — unparseable types are left untouched. var sessionStart, sessionEnd, stop, userPromptSubmit, preToolUse, postToolUse []ClaudeHookMatcher - parseHookType(rawHooks, "SessionStart", &sessionStart) - parseHookType(rawHooks, "SessionEnd", &sessionEnd) - parseHookType(rawHooks, "Stop", &stop) - parseHookType(rawHooks, "UserPromptSubmit", &userPromptSubmit) - parseHookType(rawHooks, "PreToolUse", &preToolUse) - parseHookType(rawHooks, "PostToolUse", &postToolUse) + parsedSessionStart := parseHookType(rawHooks, "SessionStart", &sessionStart) + parsedSessionEnd := parseHookType(rawHooks, "SessionEnd", &sessionEnd) + parsedStop := parseHookType(rawHooks, "Stop", &stop) + parsedUserPromptSubmit := parseHookType(rawHooks, "UserPromptSubmit", &userPromptSubmit) + parsedPreToolUse := parseHookType(rawHooks, "PreToolUse", &preToolUse) + parsedPostToolUse := parseHookType(rawHooks, "PostToolUse", &postToolUse) // If force is true, remove all existing Entire hooks first if force { @@ -203,12 +204,12 @@ func (c *ClaudeCodeAgent) InstallHooks(localDev bool, force bool) (int, error) { } // Marshal modified hook types back to rawHooks - marshalHookType(rawHooks, "SessionStart", sessionStart) - marshalHookType(rawHooks, "SessionEnd", sessionEnd) - marshalHookType(rawHooks, "Stop", stop) - marshalHookType(rawHooks, "UserPromptSubmit", userPromptSubmit) - marshalHookType(rawHooks, "PreToolUse", preToolUse) - marshalHookType(rawHooks, "PostToolUse", postToolUse) + marshalHookType(rawHooks, "SessionStart", sessionStart, parsedSessionStart) + marshalHookType(rawHooks, "SessionEnd", sessionEnd, parsedSessionEnd) + marshalHookType(rawHooks, "Stop", stop, parsedStop) + marshalHookType(rawHooks, "UserPromptSubmit", userPromptSubmit, parsedUserPromptSubmit) + marshalHookType(rawHooks, "PreToolUse", preToolUse, parsedPreToolUse) + marshalHookType(rawHooks, "PostToolUse", postToolUse, parsedPostToolUse) // Marshal hooks and update raw settings hooksJSON, err := json.Marshal(rawHooks) @@ -242,17 +243,25 @@ func (c *ClaudeCodeAgent) InstallHooks(localDev bool, force bool) (int, error) { } // parseHookType parses a specific hook type from rawHooks into the target slice. -// Silently ignores parse errors (leaves target unchanged). -func parseHookType(rawHooks map[string]json.RawMessage, hookType string, target *[]ClaudeHookMatcher) { +// Returns true if the hook type was successfully parsed (or didn't exist). +// Returns false if parsing failed — caller should NOT marshal this type back, +// to avoid replacing the original (unparseable) data with an empty array. +func parseHookType(rawHooks map[string]json.RawMessage, hookType string, target *[]ClaudeHookMatcher) bool { if data, ok := rawHooks[hookType]; ok { - //nolint:errcheck,gosec // Intentionally ignoring parse errors - leave target as nil/empty - json.Unmarshal(data, target) + if err := json.Unmarshal(data, target); err != nil { + return false + } } + return true } // marshalHookType marshals a hook type back to rawHooks. +// If parsed is false, the original data couldn't be parsed and is left untouched. // If the slice is empty, removes the key from rawHooks. -func marshalHookType(rawHooks map[string]json.RawMessage, hookType string, matchers []ClaudeHookMatcher) { +func marshalHookType(rawHooks map[string]json.RawMessage, hookType string, matchers []ClaudeHookMatcher, parsed bool) { + if !parsed { + return // Don't overwrite unparseable data + } if len(matchers) == 0 { delete(rawHooks, hookType) return @@ -293,14 +302,15 @@ func (c *ClaudeCodeAgent) UninstallHooks() error { rawHooks = make(map[string]json.RawMessage) } - // Parse only the hook types we need to modify + // Parse only the hook types we need to modify. + // Track which types parsed successfully — unparseable types are left untouched. var sessionStart, sessionEnd, stop, userPromptSubmit, preToolUse, postToolUse []ClaudeHookMatcher - parseHookType(rawHooks, "SessionStart", &sessionStart) - parseHookType(rawHooks, "SessionEnd", &sessionEnd) - parseHookType(rawHooks, "Stop", &stop) - parseHookType(rawHooks, "UserPromptSubmit", &userPromptSubmit) - parseHookType(rawHooks, "PreToolUse", &preToolUse) - parseHookType(rawHooks, "PostToolUse", &postToolUse) + parsedSessionStart := parseHookType(rawHooks, "SessionStart", &sessionStart) + parsedSessionEnd := parseHookType(rawHooks, "SessionEnd", &sessionEnd) + parsedStop := parseHookType(rawHooks, "Stop", &stop) + parsedUserPromptSubmit := parseHookType(rawHooks, "UserPromptSubmit", &userPromptSubmit) + parsedPreToolUse := parseHookType(rawHooks, "PreToolUse", &preToolUse) + parsedPostToolUse := parseHookType(rawHooks, "PostToolUse", &postToolUse) // Remove Entire hooks from all hook types sessionStart = removeEntireHooks(sessionStart) @@ -311,12 +321,12 @@ func (c *ClaudeCodeAgent) UninstallHooks() error { postToolUse = removeEntireHooksFromMatchers(postToolUse) // Marshal modified hook types back to rawHooks - marshalHookType(rawHooks, "SessionStart", sessionStart) - marshalHookType(rawHooks, "SessionEnd", sessionEnd) - marshalHookType(rawHooks, "Stop", stop) - marshalHookType(rawHooks, "UserPromptSubmit", userPromptSubmit) - marshalHookType(rawHooks, "PreToolUse", preToolUse) - marshalHookType(rawHooks, "PostToolUse", postToolUse) + marshalHookType(rawHooks, "SessionStart", sessionStart, parsedSessionStart) + marshalHookType(rawHooks, "SessionEnd", sessionEnd, parsedSessionEnd) + marshalHookType(rawHooks, "Stop", stop, parsedStop) + marshalHookType(rawHooks, "UserPromptSubmit", userPromptSubmit, parsedUserPromptSubmit) + marshalHookType(rawHooks, "PreToolUse", preToolUse, parsedPreToolUse) + marshalHookType(rawHooks, "PostToolUse", postToolUse, parsedPostToolUse) // Also remove the metadata deny rule from permissions var rawPermissions map[string]json.RawMessage diff --git a/cmd/entire/cli/agent/claudecode/prompter.go b/cmd/entire/cli/agent/claudecode/prompter.go new file mode 100644 index 000000000..4c44b9c37 --- /dev/null +++ b/cmd/entire/cli/agent/claudecode/prompter.go @@ -0,0 +1,89 @@ +package claudecode + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Compile-time assertion that ClaudeCodeAgent implements agent.Prompter. +var _ agent.Prompter = (*ClaudeCodeAgent)(nil) + +// CLICommand returns the CLI executable name for Claude Code. +func (c *ClaudeCodeAgent) CLICommand() string { + return "claude" +} + +// Prompt sends a prompt to the Claude CLI and returns the text response. +func (c *ClaudeCodeAgent) Prompt(ctx context.Context, prompt string, opts agent.PromptOptions) (*agent.PromptResult, error) { + args := []string{"--print", "--setting-sources", ""} + + outputFormat := opts.OutputFormat + if outputFormat == "" { + outputFormat = "json" + } + args = append(args, "--output-format", outputFormat) + + if opts.Model != "" { + args = append(args, "--model", opts.Model) + } + if opts.AllowedTools != "" { + args = append(args, "--allowedTools", opts.AllowedTools) + } + if opts.PermissionMode != "" { + args = append(args, "--permission-mode", opts.PermissionMode) + } + + runner := c.CommandRunner + if runner == nil { + runner = exec.CommandContext + } + + cmd := runner(ctx, c.CLICommand(), args...) + + // Working directory + if opts.WorkDir != "" { + cmd.Dir = opts.WorkDir + } else { + cmd.Dir = os.TempDir() + } + + // Environment isolation + isolate := true + if opts.IsolateFromGit != nil { + isolate = *opts.IsolateFromGit + } + if isolate { + cmd.Env = agent.StripGitEnv(os.Environ()) + } else { + cmd.Env = os.Environ() + } + cmd.Env = append(cmd.Env, opts.ExtraEnv...) + + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("claude prompt failed: %w", agent.FormatExecError(err, "claude", stderr.String())) + } + + // Parse response based on output format + if outputFormat == "json" { + var cliResp agent.CLIResponse + if err := json.Unmarshal(stdout.Bytes(), &cliResp); err != nil { + return nil, fmt.Errorf("failed to parse claude CLI response: %w", err) + } + return &agent.PromptResult{Text: cliResp.Result}, nil + } + + return &agent.PromptResult{Text: stdout.String()}, nil +} diff --git a/cmd/entire/cli/agent/claudecode/prompter_test.go b/cmd/entire/cli/agent/claudecode/prompter_test.go new file mode 100644 index 000000000..0c7463f5b --- /dev/null +++ b/cmd/entire/cli/agent/claudecode/prompter_test.go @@ -0,0 +1,256 @@ +package claudecode + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestCLICommand(t *testing.T) { + t.Parallel() + ag := &ClaudeCodeAgent{} + if ag.CLICommand() != "claude" { + t.Errorf("CLICommand() = %q, want %q", ag.CLICommand(), "claude") + } +} + +func TestPrompt_ValidJSONResponse(t *testing.T) { + t.Parallel() + + ag := &ClaudeCodeAgent{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", `printf '{"result":"Hello from Claude"}'`) + }, + } + + result, err := ag.Prompt(context.Background(), "Say hello", agent.PromptOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Text != "Hello from Claude" { + t.Errorf("Text = %q, want %q", result.Text, "Hello from Claude") + } +} + +func TestPrompt_TextOutputFormat(t *testing.T) { + t.Parallel() + + ag := &ClaudeCodeAgent{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf 'plain text response'") + }, + } + + result, err := ag.Prompt(context.Background(), "Say hello", agent.PromptOptions{ + OutputFormat: "text", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Text != "plain text response" { + t.Errorf("Text = %q, want %q", result.Text, "plain text response") + } +} + +func TestPrompt_CommandArgs(t *testing.T) { + t.Parallel() + + var capturedArgs []string + + ag := &ClaudeCodeAgent{ + CommandRunner: func(ctx context.Context, name string, args ...string) *exec.Cmd { + capturedArgs = append([]string{name}, args...) + return exec.CommandContext(ctx, "sh", "-c", `printf '{"result":"ok"}'`) + }, + } + + _, err := ag.Prompt(context.Background(), "test prompt", agent.PromptOptions{ + Model: "opus", + AllowedTools: "Read,Glob,Grep", + PermissionMode: "bypassPermissions", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + argsStr := strings.Join(capturedArgs, " ") + for _, expected := range []string{ + "claude", + "--print", + "--setting-sources", + "--output-format json", + "--model opus", + "--allowedTools Read,Glob,Grep", + "--permission-mode bypassPermissions", + } { + if !strings.Contains(argsStr, expected) { + t.Errorf("expected args to contain %q, got: %s", expected, argsStr) + } + } +} + +func TestPrompt_WorkDir_Custom(t *testing.T) { + t.Parallel() + + var capturedCmd *exec.Cmd + ag := &ClaudeCodeAgent{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "sh", "-c", `printf '{"result":"ok"}'`) + capturedCmd = cmd + return cmd + }, + } + + customDir := t.TempDir() + _, err := ag.Prompt(context.Background(), "test", agent.PromptOptions{ + WorkDir: customDir, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capturedCmd.Dir != customDir { + t.Errorf("Dir = %q, want %q", capturedCmd.Dir, customDir) + } +} + +func TestPrompt_WorkDir_Default(t *testing.T) { + t.Parallel() + + var capturedCmd *exec.Cmd + ag := &ClaudeCodeAgent{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "sh", "-c", `printf '{"result":"ok"}'`) + capturedCmd = cmd + return cmd + }, + } + + _, err := ag.Prompt(context.Background(), "test", agent.PromptOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capturedCmd.Dir != os.TempDir() { + t.Errorf("Dir = %q, want %q", capturedCmd.Dir, os.TempDir()) + } +} + +func TestPrompt_GitIsolation(t *testing.T) { + var capturedCmd *exec.Cmd + + ag := &ClaudeCodeAgent{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "sh", "-c", `printf '{"result":"ok"}'`) + capturedCmd = cmd + return cmd + }, + } + + // Set GIT_* vars that would normally be inherited from a git hook + t.Setenv("GIT_DIR", "/some/repo/.git") + t.Setenv("GIT_WORK_TREE", "/some/repo") + t.Setenv("CLAUDECODE", "1") + + _, err := ag.Prompt(context.Background(), "test", agent.PromptOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, env := range capturedCmd.Env { + if strings.HasPrefix(env, "GIT_") { + t.Errorf("found GIT_* env var in subprocess: %s", env) + } + if strings.HasPrefix(env, "CLAUDECODE=") { + t.Errorf("found CLAUDECODE env var in subprocess: %s", env) + } + } +} + +func TestPrompt_ExtraEnv(t *testing.T) { + var capturedCmd *exec.Cmd + + ag := &ClaudeCodeAgent{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "sh", "-c", `printf '{"result":"ok"}'`) + capturedCmd = cmd + return cmd + }, + } + + t.Setenv("GIT_DIR", "") + + _, err := ag.Prompt(context.Background(), "test", agent.PromptOptions{ + ExtraEnv: []string{"ENTIRE_WINGMAN_APPLY=1"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + found := false + for _, env := range capturedCmd.Env { + if env == "ENTIRE_WINGMAN_APPLY=1" { + found = true + break + } + } + if !found { + t.Error("expected ENTIRE_WINGMAN_APPLY=1 in subprocess env") + } +} + +func TestPrompt_CommandNotFound(t *testing.T) { + t.Parallel() + + ag := &ClaudeCodeAgent{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "nonexistent-command-that-should-not-exist-12345") + }, + } + + _, err := ag.Prompt(context.Background(), "test", agent.PromptOptions{}) + if err == nil { + t.Fatal("expected error when command not found") + } + if !strings.Contains(err.Error(), "not found") && !strings.Contains(err.Error(), "executable file not found") { + t.Errorf("expected 'not found' error, got: %v", err) + } +} + +func TestPrompt_NonZeroExit(t *testing.T) { + t.Parallel() + + ag := &ClaudeCodeAgent{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "echo 'error message' >&2; exit 1") + }, + } + + _, err := ag.Prompt(context.Background(), "test", agent.PromptOptions{}) + if err == nil { + t.Fatal("expected error on non-zero exit") + } + if !strings.Contains(err.Error(), "exit 1") { + t.Errorf("expected exit code in error, got: %v", err) + } +} + +func TestPrompt_InvalidJSONResponse(t *testing.T) { + t.Parallel() + + ag := &ClaudeCodeAgent{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf 'not valid json'") + }, + } + + _, err := ag.Prompt(context.Background(), "test", agent.PromptOptions{}) + if err == nil { + t.Fatal("expected error for invalid JSON") + } + if !strings.Contains(err.Error(), "failed to parse claude CLI response") { + t.Errorf("expected parse error, got: %v", err) + } +} diff --git a/cmd/entire/cli/agent/cli_helpers.go b/cmd/entire/cli/agent/cli_helpers.go new file mode 100644 index 000000000..f758334e1 --- /dev/null +++ b/cmd/entire/cli/agent/cli_helpers.go @@ -0,0 +1,44 @@ +package agent + +import ( + "errors" + "fmt" + "os/exec" + "strings" +) + +// CLIResponse represents the JSON response from agent CLIs that support --output-format json. +// Used by both wingman review and summarization. +type CLIResponse struct { + Result string `json:"result"` +} + +// StripGitEnv returns a copy of env with all GIT_* variables removed and +// agent-specific nesting-detection variables unset (e.g., CLAUDECODE). +// GIT_* removal prevents a subprocess from discovering or modifying the +// parent's git repo. CLAUDECODE removal prevents the Claude CLI from +// refusing to start due to nested-session detection. +func StripGitEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if strings.HasPrefix(e, "GIT_") || strings.HasPrefix(e, "CLAUDECODE=") { + continue + } + filtered = append(filtered, e) + } + return filtered +} + +// FormatExecError returns a human-readable error message for exec failures. +// Handles both "command not found" (exec.Error) and non-zero exit (exec.ExitError). +func FormatExecError(err error, cliName, stderr string) error { + var execErr *exec.Error + if errors.As(err, &execErr) { + return fmt.Errorf("%s CLI not found: %w", cliName, err) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return fmt.Errorf("%s CLI failed (exit %d): %s", cliName, exitErr.ExitCode(), stderr) + } + return fmt.Errorf("failed to run %s CLI: %w", cliName, err) +} diff --git a/cmd/entire/cli/agent/cli_helpers_test.go b/cmd/entire/cli/agent/cli_helpers_test.go new file mode 100644 index 000000000..541a1dc0a --- /dev/null +++ b/cmd/entire/cli/agent/cli_helpers_test.go @@ -0,0 +1,102 @@ +package agent + +import ( + "context" + "errors" + "os/exec" + "strings" + "testing" +) + +func TestStripGitEnv(t *testing.T) { + t.Parallel() + + env := []string{ + "HOME=/Users/test", + "GIT_DIR=/repo/.git", + "PATH=/usr/bin", + "GIT_WORK_TREE=/repo", + "GIT_INDEX_FILE=/repo/.git/index", + "SHELL=/bin/zsh", + "CLAUDECODE=1", + } + + filtered := StripGitEnv(env) + + expected := []string{ + "HOME=/Users/test", + "PATH=/usr/bin", + "SHELL=/bin/zsh", + } + + if len(filtered) != len(expected) { + t.Fatalf("got %d entries, want %d", len(filtered), len(expected)) + } + + for i, e := range filtered { + if e != expected[i] { + t.Errorf("filtered[%d] = %q, want %q", i, e, expected[i]) + } + } +} + +func TestStripGitEnv_NoGitVars(t *testing.T) { + t.Parallel() + + env := []string{"HOME=/Users/test", "PATH=/usr/bin"} + filtered := StripGitEnv(env) + + if len(filtered) != 2 { + t.Errorf("expected 2 entries, got %d", len(filtered)) + } +} + +func TestStripGitEnv_Empty(t *testing.T) { + t.Parallel() + + filtered := StripGitEnv(nil) + if len(filtered) != 0 { + t.Errorf("expected 0 entries, got %d", len(filtered)) + } +} + +func TestFormatExecError_NotFound(t *testing.T) { + t.Parallel() + + err := &exec.Error{Name: "claude", Err: errors.New("not found")} + result := FormatExecError(err, "claude", "") + + if !strings.Contains(result.Error(), "claude CLI not found") { + t.Errorf("expected 'claude CLI not found', got: %v", result) + } +} + +func TestFormatExecError_ExitError(t *testing.T) { + t.Parallel() + + // Use a real command that will fail to get an ExitError + cmd := exec.CommandContext(context.Background(), "sh", "-c", "exit 42") + err := cmd.Run() + result := FormatExecError(err, "claude", "some stderr output") + + if !strings.Contains(result.Error(), "claude CLI failed") { + t.Errorf("expected 'claude CLI failed', got: %v", result) + } + if !strings.Contains(result.Error(), "exit 42") { + t.Errorf("expected exit code 42, got: %v", result) + } + if !strings.Contains(result.Error(), "some stderr output") { + t.Errorf("expected stderr content, got: %v", result) + } +} + +func TestFormatExecError_GenericError(t *testing.T) { + t.Parallel() + + err := errors.New("connection refused") + result := FormatExecError(err, "claude", "") + + if !strings.Contains(result.Error(), "failed to run claude CLI") { + t.Errorf("expected 'failed to run claude CLI', got: %v", result) + } +} diff --git a/cmd/entire/cli/agent/prompter.go b/cmd/entire/cli/agent/prompter.go new file mode 100644 index 000000000..7c7de085b --- /dev/null +++ b/cmd/entire/cli/agent/prompter.go @@ -0,0 +1,63 @@ +package agent + +import "context" + +// PromptOptions configures how an agent CLI is invoked for a prompt. +type PromptOptions struct { + // Model overrides the default model for this invocation. + // If empty, uses the agent's default. + Model string + + // WorkDir is the working directory for the CLI subprocess. + // If empty, uses os.TempDir() for isolation. + WorkDir string + + // AllowedTools restricts which tools the agent can use (e.g., "Read,Glob,Grep"). + // If empty, no tool restriction is applied. + AllowedTools string + + // PermissionMode controls the agent's permission behavior. + // Values: "bypassPermissions", "acceptEdits", etc. + // If empty, uses the agent's default. + PermissionMode string + + // OutputFormat controls the output format (e.g., "json", "text"). + // If empty, defaults to "json". + OutputFormat string + + // IsolateFromGit strips GIT_* and agent-specific env vars to prevent + // interference with the parent's git state. Defaults to true when nil. + IsolateFromGit *bool + + // ExtraEnv adds additional environment variables to the subprocess. + ExtraEnv []string +} + +// PromptResult contains the response from an agent prompt invocation. +type PromptResult struct { + // Text is the extracted text response from the agent. + Text string +} + +// Prompter is an optional interface for agents that support CLI-based prompting. +// Agents implementing this interface can be invoked with a prompt string and return +// a text response. This powers features like wingman review and summarization. +// +// This follows the same optional interface pattern as TranscriptAnalyzer, +// TokenCalculator, etc. Use a type assertion to check if an agent supports it: +// +// if prompter, ok := ag.(agent.Prompter); ok { +// result, err := prompter.Prompt(ctx, prompt, opts) +// } +type Prompter interface { + Agent + + // Prompt sends a prompt to the agent CLI and returns the text response. + // The implementation handles CLI invocation, environment isolation, + // output parsing, and error handling. + Prompt(ctx context.Context, prompt string, opts PromptOptions) (*PromptResult, error) + + // CLICommand returns the CLI executable name for this agent (e.g., "claude"). + // Used for error messages and logging. + CLICommand() string +} diff --git a/cmd/entire/cli/hooks.go b/cmd/entire/cli/hooks.go index e26e9d15a..4cb763f7e 100644 --- a/cmd/entire/cli/hooks.go +++ b/cmd/entire/cli/hooks.go @@ -232,16 +232,60 @@ func logPostTaskHookContext(w io.Writer, input *PostTaskHookInput, subagentTrans } } -// hookResponse represents a JSON response. -// Used to control whether Agent continues processing the prompt. +// hookSpecificOutput contains event-specific fields nested under hookSpecificOutput +// in the hook response JSON. Claude Code requires this nesting for additionalContext +// to be injected into the agent's conversation. +type hookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + AdditionalContext string `json:"additionalContext,omitempty"` +} + +// hookResponse represents a JSON response to a hook. +// systemMessage is shown to the user as a warning/info message. +// hookSpecificOutput contains event-specific fields like additionalContext. type hookResponse struct { - SystemMessage string `json:"systemMessage,omitempty"` + SystemMessage string `json:"systemMessage,omitempty"` + HookSpecificOutput *hookSpecificOutput `json:"hookSpecificOutput,omitempty"` +} + +// outputHookResponse outputs a JSON response with additionalContext for +// SessionStart hooks. The context is injected into the agent's conversation. +func outputHookResponse(additionalContext string) error { + resp := hookResponse{ + SystemMessage: additionalContext, + HookSpecificOutput: &hookSpecificOutput{ + HookEventName: "SessionStart", + AdditionalContext: additionalContext, + }, + } + if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil { + return fmt.Errorf("failed to encode hook response: %w", err) + } + return nil } -// outputHookResponse outputs a JSON response to stdout -func outputHookResponse(reason string) error { +// outputHookMessage outputs a JSON response with only a systemMessage — shown +// to the user in the terminal but NOT injected into the agent's conversation. +// Use this for informational notifications (e.g., wingman status) that the user +// should see but the agent should not act on. +func outputHookMessage(message string) error { + resp := hookResponse{SystemMessage: message} + if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil { + return fmt.Errorf("failed to encode hook response: %w", err) + } + return nil +} + +// outputHookResponseWithContextAndMessage outputs a JSON response with both +// additionalContext (injected into agent conversation) and a systemMessage +// (shown to the user as a warning/info). +func outputHookResponseWithContextAndMessage(additionalContext, message string) error { resp := hookResponse{ - SystemMessage: reason, + SystemMessage: message, + HookSpecificOutput: &hookSpecificOutput{ + HookEventName: "UserPromptSubmit", + AdditionalContext: additionalContext, + }, } if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil { return fmt.Errorf("failed to encode hook response: %w", err) diff --git a/cmd/entire/cli/hooks_git_cmd.go b/cmd/entire/cli/hooks_git_cmd.go index e97156676..c98e6dc22 100644 --- a/cmd/entire/cli/hooks_git_cmd.go +++ b/cmd/entire/cli/hooks_git_cmd.go @@ -163,6 +163,10 @@ func newHooksGitPostCommitCmd() *cobra.Command { g.logCompleted(hookErr) } + // Trigger wingman review after commit (manual-commit strategy). + // Auto-commit triggers from the stop hook instead. + triggerWingmanFromCommit() + return nil }, } diff --git a/cmd/entire/cli/hooks_test.go b/cmd/entire/cli/hooks_test.go index b84dda1f5..bcb9e2a0a 100644 --- a/cmd/entire/cli/hooks_test.go +++ b/cmd/entire/cli/hooks_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "encoding/json" "strings" "testing" ) @@ -539,3 +540,172 @@ func TestLogPostTaskHookContext(t *testing.T) { }) } } + +func TestHookResponse_SessionStart(t *testing.T) { + t.Parallel() + + resp := hookResponse{ + SystemMessage: "Powered by Entire", + HookSpecificOutput: &hookSpecificOutput{ + HookEventName: "SessionStart", + AdditionalContext: "Powered by Entire", + }, + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + // Verify the nested structure + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + // systemMessage should be present (same as additionalContext for user visibility) + if _, ok := raw["systemMessage"]; !ok { + t.Error("systemMessage should be present for SessionStart") + } + + // hookSpecificOutput should be present + hsoRaw, ok := raw["hookSpecificOutput"] + if !ok { + t.Fatal("hookSpecificOutput missing from response") + } + + var hso map[string]string + if err := json.Unmarshal(hsoRaw, &hso); err != nil { + t.Fatalf("failed to unmarshal hookSpecificOutput: %v", err) + } + + if hso["hookEventName"] != "SessionStart" { + t.Errorf("hookEventName = %q, want %q", hso["hookEventName"], "SessionStart") + } + if hso["additionalContext"] != "Powered by Entire" { + t.Errorf("additionalContext = %q, want %q", hso["additionalContext"], "Powered by Entire") + } +} + +func TestHookResponse_UserPromptSubmit(t *testing.T) { + t.Parallel() + + resp := hookResponse{ + HookSpecificOutput: &hookSpecificOutput{ + HookEventName: "UserPromptSubmit", + AdditionalContext: "Review instructions here", + }, + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + // systemMessage should be absent + if _, ok := raw["systemMessage"]; ok { + t.Error("systemMessage should be omitted when empty") + } + + hsoRaw, ok := raw["hookSpecificOutput"] + if !ok { + t.Fatal("hookSpecificOutput missing from response") + } + + var hso map[string]string + if err := json.Unmarshal(hsoRaw, &hso); err != nil { + t.Fatalf("failed to unmarshal hookSpecificOutput: %v", err) + } + + if hso["hookEventName"] != "UserPromptSubmit" { + t.Errorf("hookEventName = %q, want %q", hso["hookEventName"], "UserPromptSubmit") + } + if hso["additionalContext"] != "Review instructions here" { + t.Errorf("additionalContext = %q, want %q", hso["additionalContext"], "Review instructions here") + } +} + +func TestHookResponse_WithContextAndMessage(t *testing.T) { + t.Parallel() + + resp := hookResponse{ + SystemMessage: "[Wingman] A code review is pending.", + HookSpecificOutput: &hookSpecificOutput{ + HookEventName: "UserPromptSubmit", + AdditionalContext: "Apply the review", + }, + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + // systemMessage should be present + var sysMsg string + if err := json.Unmarshal(raw["systemMessage"], &sysMsg); err != nil { + t.Fatalf("failed to unmarshal systemMessage: %v", err) + } + if sysMsg != "[Wingman] A code review is pending." { + t.Errorf("systemMessage = %q, want %q", sysMsg, "[Wingman] A code review is pending.") + } + + // hookSpecificOutput should also be present + hsoRaw, ok := raw["hookSpecificOutput"] + if !ok { + t.Fatal("hookSpecificOutput missing from response") + } + + var hso map[string]string + if err := json.Unmarshal(hsoRaw, &hso); err != nil { + t.Fatalf("failed to unmarshal hookSpecificOutput: %v", err) + } + + if hso["hookEventName"] != "UserPromptSubmit" { + t.Errorf("hookEventName = %q, want %q", hso["hookEventName"], "UserPromptSubmit") + } + if hso["additionalContext"] != "Apply the review" { + t.Errorf("additionalContext = %q, want %q", hso["additionalContext"], "Apply the review") + } +} + +func TestHookResponse_NilHookSpecificOutput(t *testing.T) { + t.Parallel() + + resp := hookResponse{ + SystemMessage: "Just a message", + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + // hookSpecificOutput should be absent (omitempty on pointer) + if _, ok := raw["hookSpecificOutput"]; ok { + t.Error("hookSpecificOutput should be omitted when nil") + } + + var sysMsg string + if err := json.Unmarshal(raw["systemMessage"], &sysMsg); err != nil { + t.Fatalf("failed to unmarshal systemMessage: %v", err) + } + if sysMsg != "Just a message" { + t.Errorf("systemMessage = %q, want %q", sysMsg, "Just a message") + } +} diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 9c34d9041..6feef9732 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -76,6 +76,9 @@ func handleLifecycleSessionStart(ag agent.Agent, event *agent.Event) error { // Build informational message message := "\n\nPowered by Entire:\n This conversation will be linked to your next commit." + // Append wingman status if enabled + message = appendWingmanSessionStartStatus(message) + // Check for concurrent sessions and append count if any strat := GetStrategy() if concurrentChecker, ok := strat.(strategy.ConcurrentSessionChecker); ok { @@ -130,6 +133,13 @@ func handleLifecycleTurnStart(ag agent.Agent, event *agent.Event) error { return err } + // If a wingman review is pending, inject it as additionalContext so the + // agent addresses it BEFORE the user's request. Must return early to + // avoid corrupting the JSON stdout with additional output. + if handleWingmanTurnStart(sessionID) { + return nil + } + // Ensure strategy setup and initialize session strat := GetStrategy() @@ -323,6 +333,8 @@ func handleLifecycleTurnEnd(ag agent.Agent, event *agent.Event) error { fmt.Fprintf(os.Stderr, "No files were modified during this session\n") fmt.Fprintf(os.Stderr, "Skipping commit\n") transitionSessionTurnEnd(sessionID) + // Auto-apply pending wingman review even when no file changes this turn + handleWingmanTurnEnd(sessionID, 0, nil, nil, nil, nil, "") if cleanupErr := CleanupPrePromptState(sessionID); cleanupErr != nil { fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) } @@ -405,6 +417,10 @@ func handleLifecycleTurnEnd(ag agent.Agent, event *agent.Event) error { // Transition session phase and cleanup transitionSessionTurnEnd(sessionID) + + // Wingman: trigger review (auto-commit), auto-apply, and stop notification + handleWingmanTurnEnd(sessionID, totalChanges, relModifiedFiles, relNewFiles, relDeletedFiles, allPrompts, commitMessage) + if cleanupErr := CleanupPrePromptState(sessionID); cleanupErr != nil { fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) } diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 5fedf6ad4..d84008279 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -85,6 +85,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newDoctorCmd()) cmd.AddCommand(newSendAnalyticsCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) + cmd.AddCommand(newWingmanCmd()) // Replace default help command with custom one that supports -t flag cmd.SetHelpCommand(NewHelpCmd(cmd)) diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 381c9993a..411d33134 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -30,6 +30,9 @@ type EntireSettings struct { // Strategy is the name of the git strategy to use Strategy string `json:"strategy"` + // Agent is the name of the agent (e.g., "claude-code", "amp") + Agent string `json:"agent,omitempty"` + // Enabled indicates whether Entire is active. When false, CLI commands // show a disabled message and hooks exit silently. Defaults to true. Enabled bool `json:"enabled"` @@ -151,6 +154,17 @@ func mergeJSON(settings *EntireSettings, data []byte) error { } } + // Override agent if present and non-empty + if agentRaw, ok := raw["agent"]; ok { + var a string + if err := json.Unmarshal(agentRaw, &a); err != nil { + return fmt.Errorf("parsing agent field: %w", err) + } + if a != "" { + settings.Agent = a + } + } + // Override enabled if present if enabledRaw, ok := raw["enabled"]; ok { var e bool @@ -239,6 +253,32 @@ func (s *EntireSettings) IsSummarizeEnabled() bool { return enabled } +// IsWingmanEnabled checks if wingman auto-review is enabled in settings. +// Returns false by default if settings cannot be loaded or the key is missing. +func IsWingmanEnabled() bool { + s, err := Load() + if err != nil { + return false + } + return s.IsWingmanEnabled() +} + +// IsWingmanEnabled checks if wingman auto-review is enabled in this settings instance. +func (s *EntireSettings) IsWingmanEnabled() bool { + if s.StrategyOptions == nil { + return false + } + wingmanOpts, ok := s.StrategyOptions["wingman"].(map[string]any) + if !ok { + return false + } + enabled, ok := wingmanOpts["enabled"].(bool) + if !ok { + return false + } + return enabled +} + // IsPushSessionsDisabled checks if push_sessions is disabled in settings. // Returns true if push_sessions is explicitly set to false. func (s *EntireSettings) IsPushSessionsDisabled() bool { @@ -255,6 +295,47 @@ func (s *EntireSettings) IsPushSessionsDisabled() bool { return false } +// WingmanAgent returns the configured agent for wingman reviews. +// Returns empty string if not configured (callers should default to the default agent). +func (s *EntireSettings) WingmanAgent() string { + return s.strategyOptionString("wingman", "agent") +} + +// WingmanModel returns the configured model for wingman reviews. +// Returns empty string if not configured (callers should default to their own default). +func (s *EntireSettings) WingmanModel() string { + return s.strategyOptionString("wingman", "model") +} + +// SummarizeAgent returns the configured agent for summarization. +// Returns empty string if not configured (callers should default to the default agent). +func (s *EntireSettings) SummarizeAgent() string { + return s.strategyOptionString("summarize", "agent") +} + +// SummarizeModel returns the configured model for summarization. +// Returns empty string if not configured (callers should default to their own default). +func (s *EntireSettings) SummarizeModel() string { + return s.strategyOptionString("summarize", "model") +} + +// strategyOptionString extracts a string value from a nested strategy option. +// Returns empty string if the option doesn't exist or isn't a string. +func (s *EntireSettings) strategyOptionString(section, key string) string { + if s.StrategyOptions == nil { + return "" + } + sectionOpts, ok := s.StrategyOptions[section].(map[string]any) + if !ok { + return "" + } + val, ok := sectionOpts[key].(string) + if !ok { + return "" + } + return val +} + // Save saves the settings to .entire/settings.json. func Save(settings *EntireSettings) error { return saveToFile(settings, EntireSettingsFile) diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index ad09bc57a..e80646263 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -140,3 +140,102 @@ func containsUnknownField(msg string) bool { // Go's json package reports unknown fields with this message format return strings.Contains(msg, "unknown field") } + +func TestStrategyOptionString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + options map[string]any + section string + key string + expected string + }{ + { + name: "nil options", + options: nil, + section: "wingman", + key: "agent", + expected: "", + }, + { + name: "missing section", + options: map[string]any{}, + section: "wingman", + key: "agent", + expected: "", + }, + { + name: "section wrong type", + options: map[string]any{"wingman": "invalid"}, + section: "wingman", + key: "agent", + expected: "", + }, + { + name: "missing key", + options: map[string]any{"wingman": map[string]any{"enabled": true}}, + section: "wingman", + key: "agent", + expected: "", + }, + { + name: "key wrong type", + options: map[string]any{"wingman": map[string]any{"agent": 123}}, + section: "wingman", + key: "agent", + expected: "", + }, + { + name: "wingman agent", + options: map[string]any{"wingman": map[string]any{"agent": "claude-code", "model": "opus"}}, + section: "wingman", + key: "agent", + expected: "claude-code", + }, + { + name: "wingman model", + options: map[string]any{"wingman": map[string]any{"agent": "claude-code", "model": "opus"}}, + section: "wingman", + key: "model", + expected: "opus", + }, + { + name: "summarize agent", + options: map[string]any{"summarize": map[string]any{"agent": "gemini"}}, + section: "summarize", + key: "agent", + expected: "gemini", + }, + { + name: "summarize model", + options: map[string]any{"summarize": map[string]any{"model": "sonnet"}}, + section: "summarize", + key: "model", + expected: "sonnet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + s := &EntireSettings{StrategyOptions: tt.options} + + var got string + switch { + case tt.section == "wingman" && tt.key == "agent": + got = s.WingmanAgent() + case tt.section == "wingman" && tt.key == "model": + got = s.WingmanModel() + case tt.section == "summarize" && tt.key == "agent": + got = s.SummarizeAgent() + case tt.section == "summarize" && tt.key == "model": + got = s.SummarizeModel() + } + + if got != tt.expected { + t.Errorf("%s.%s = %q, want %q", tt.section, tt.key, got, tt.expected) + } + }) + } +} diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 6d4165d9e..8522bcb2a 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -683,6 +683,10 @@ func EnsureEntireGitignore() error { "settings.local.json", "metadata/", "logs/", + "wingman.lock", + "wingman-state.json", + "wingman-payload.json", + "REVIEW.md", } // Track what needs to be added diff --git a/cmd/entire/cli/summarize/claude.go b/cmd/entire/cli/summarize/claude.go index 7b2e6dd3e..542ea512f 100644 --- a/cmd/entire/cli/summarize/claude.go +++ b/cmd/entire/cli/summarize/claude.go @@ -4,12 +4,12 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "os" "os/exec" "strings" + "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint" ) @@ -51,7 +51,41 @@ Guidelines: // to handle long transcripts without truncation. const DefaultModel = "sonnet" -// ClaudeGenerator generates summaries using the Claude CLI. +// PrompterGenerator generates summaries using any agent that implements agent.Prompter. +type PrompterGenerator struct { + // Prompter is the agent's Prompter interface to invoke. + Prompter agent.Prompter + + // Model overrides the default model for summarization. + // If empty, defaults to DefaultModel ("sonnet"). + Model string +} + +// Generate creates a summary by calling the agent's Prompter interface. +func (g *PrompterGenerator) Generate(ctx context.Context, input Input) (*checkpoint.Summary, error) { + transcriptText := FormatCondensedTranscript(input) + prompt := buildSummarizationPrompt(transcriptText) + + model := g.Model + if model == "" { + model = DefaultModel + } + + result, err := g.Prompter.Prompt(ctx, prompt, agent.PromptOptions{ + Model: model, + OutputFormat: "json", + // WorkDir defaults to os.TempDir() in the Prompter implementation, + // isolating the subprocess from the user's git repo. + }) + if err != nil { + return nil, fmt.Errorf("summarize prompt failed: %w", err) + } + + return parseSummaryFromResult(result.Text) +} + +// ClaudeGenerator generates summaries using the Claude CLI directly. +// Kept for backward compatibility and test injection via CommandRunner. type ClaudeGenerator struct { // ClaudePath is the path to the claude CLI executable. // If empty, defaults to "claude" (expects it to be in PATH). @@ -66,11 +100,6 @@ type ClaudeGenerator struct { CommandRunner func(ctx context.Context, name string, args ...string) *exec.Cmd } -// claudeCLIResponse represents the JSON response from the Claude CLI. -type claudeCLIResponse struct { - Result string `json:"result"` -} - // Generate creates a summary from checkpoint data by calling the Claude CLI. func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoint.Summary, error) { // Format the transcript for the prompt @@ -107,7 +136,7 @@ func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoin // git hooks set GIT_DIR which lets Claude Code find the repo regardless of cwd. // This also prevents recursive triggering of Entire's own git hooks. cmd.Dir = os.TempDir() - cmd.Env = stripGitEnv(os.Environ()) + cmd.Env = agent.StripGitEnv(os.Environ()) // Pass prompt via stdin cmd.Stdin = strings.NewReader(prompt) @@ -118,32 +147,22 @@ func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoin err := cmd.Run() if err != nil { - // Check if the command was not found - var execErr *exec.Error - if errors.As(err, &execErr) { - return nil, fmt.Errorf("claude CLI not found: %w", err) - } - - // Check for exit error - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return nil, fmt.Errorf("claude CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) - } - - return nil, fmt.Errorf("failed to run claude CLI: %w", err) + return nil, fmt.Errorf("summarize CLI failed: %w", agent.FormatExecError(err, "claude", stderr.String())) } // Parse the CLI response - var cliResponse claudeCLIResponse + var cliResponse agent.CLIResponse if err := json.Unmarshal(stdout.Bytes(), &cliResponse); err != nil { return nil, fmt.Errorf("failed to parse claude CLI response: %w", err) } - // The result field contains the actual JSON summary - resultJSON := cliResponse.Result + return parseSummaryFromResult(cliResponse.Result) +} +// parseSummaryFromResult parses the JSON summary from an agent's text result. +func parseSummaryFromResult(resultText string) (*checkpoint.Summary, error) { // Try to extract JSON if it's wrapped in markdown code blocks - resultJSON = extractJSONFromMarkdown(resultJSON) + resultJSON := extractJSONFromMarkdown(resultText) // Parse the summary from the result var summary checkpoint.Summary @@ -159,18 +178,6 @@ func buildSummarizationPrompt(transcriptText string) string { return fmt.Sprintf(summarizationPromptTemplate, transcriptText) } -// stripGitEnv returns a copy of env with all GIT_* variables removed. -// This prevents a subprocess from discovering or modifying the parent's git repo. -func stripGitEnv(env []string) []string { - filtered := make([]string, 0, len(env)) - for _, e := range env { - if !strings.HasPrefix(e, "GIT_") { - filtered = append(filtered, e) - } - } - return filtered -} - // extractJSONFromMarkdown attempts to extract JSON from markdown code blocks. // If the input is not wrapped in code blocks, it returns the input unchanged. func extractJSONFromMarkdown(s string) string { diff --git a/cmd/entire/cli/summarize/claude_test.go b/cmd/entire/cli/summarize/claude_test.go index 58eb40435..a2b4ec3a0 100644 --- a/cmd/entire/cli/summarize/claude_test.go +++ b/cmd/entire/cli/summarize/claude_test.go @@ -26,6 +26,8 @@ func TestClaudeGenerator_GitIsolation(t *testing.T) { t.Setenv("GIT_DIR", "/some/repo/.git") t.Setenv("GIT_WORK_TREE", "/some/repo") t.Setenv("GIT_INDEX_FILE", "/some/repo/.git/index") + // Set CLAUDECODE which is set when running inside Claude Code + t.Setenv("CLAUDECODE", "1") input := Input{ Transcript: []Entry{ @@ -47,39 +49,13 @@ func TestClaudeGenerator_GitIsolation(t *testing.T) { t.Errorf("cmd.Dir = %q, want %q", capturedCmd.Dir, os.TempDir()) } - // Verify no GIT_* env vars in the command's environment + // Verify no GIT_* or CLAUDECODE env vars in the command's environment for _, env := range capturedCmd.Env { if strings.HasPrefix(env, "GIT_") { t.Errorf("found GIT_* env var in subprocess: %s", env) } - } -} - -func TestStripGitEnv(t *testing.T) { - env := []string{ - "HOME=/Users/test", - "GIT_DIR=/repo/.git", - "PATH=/usr/bin", - "GIT_WORK_TREE=/repo", - "GIT_INDEX_FILE=/repo/.git/index", - "SHELL=/bin/zsh", - } - - filtered := stripGitEnv(env) - - expected := []string{ - "HOME=/Users/test", - "PATH=/usr/bin", - "SHELL=/bin/zsh", - } - - if len(filtered) != len(expected) { - t.Fatalf("got %d entries, want %d", len(filtered), len(expected)) - } - - for i, e := range filtered { - if e != expected[i] { - t.Errorf("filtered[%d] = %q, want %q", i, e, expected[i]) + if strings.HasPrefix(env, "CLAUDECODE=") { + t.Errorf("found CLAUDECODE env var in subprocess: %s", env) } } } diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 3aefde7e4..0fd55752a 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -11,6 +11,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/transcript" ) @@ -22,7 +23,7 @@ import ( // - transcriptBytes: raw transcript bytes (JSONL or JSON format depending on agent) // - filesTouched: list of files modified during the session // - agentType: the agent type to determine transcript format -// - generator: summary generator to use (if nil, uses default ClaudeGenerator) +// - generator: summary generator to use (if nil, resolves from settings) // // Returns nil, error if transcript is empty or cannot be parsed. func GenerateFromTranscript(ctx context.Context, transcriptBytes []byte, filesTouched []string, agentType agent.AgentType, generator Generator) (*checkpoint.Summary, error) { @@ -46,7 +47,7 @@ func GenerateFromTranscript(ctx context.Context, transcriptBytes []byte, filesTo // Use default generator if none provided if generator == nil { - generator = &ClaudeGenerator{} + generator = resolveGenerator() } summary, err := generator.Generate(ctx, input) @@ -57,6 +58,37 @@ func GenerateFromTranscript(ctx context.Context, transcriptBytes []byte, filesTo return summary, nil } +// resolveGenerator creates a Generator based on settings. +// If the configured agent supports the Prompter interface, uses PrompterGenerator. +// Falls back to ClaudeGenerator for backward compatibility. +func resolveGenerator() Generator { + s, err := settings.Load() + if err != nil { + return &ClaudeGenerator{} + } + + agentName := agent.AgentName(s.SummarizeAgent()) + if agentName == "" { + agentName = agent.DefaultAgentName + } + + ag, err := agent.Get(agentName) + if err != nil { + return &ClaudeGenerator{} + } + + prompter, ok := ag.(agent.Prompter) + if !ok { + // Agent doesn't support Prompter, fall back to ClaudeGenerator + return &ClaudeGenerator{} + } + + return &PrompterGenerator{ + Prompter: prompter, + Model: s.SummarizeModel(), + } +} + // Generator generates checkpoint summaries using an LLM. type Generator interface { // Generate creates a summary from checkpoint data. diff --git a/cmd/entire/cli/wingman.go b/cmd/entire/cli/wingman.go new file mode 100644 index 000000000..93910e97e --- /dev/null +++ b/cmd/entire/cli/wingman.go @@ -0,0 +1,720 @@ +package cli + +import ( + "context" + "crypto/sha256" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/charmbracelet/huh" + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/spf13/cobra" +) + +// WingmanPayload is the data passed to the detached review subprocess. +type WingmanPayload struct { + SessionID string `json:"session_id"` + RepoRoot string `json:"repo_root"` + BaseCommit string `json:"base_commit"` + ModifiedFiles []string `json:"modified_files"` + NewFiles []string `json:"new_files"` + DeletedFiles []string `json:"deleted_files"` + Prompts []string `json:"prompts"` + CommitMessage string `json:"commit_message"` +} + +// WingmanState tracks deduplication and review state. +type WingmanState struct { + SessionID string `json:"session_id"` + FilesHash string `json:"files_hash"` + ReviewedAt time.Time `json:"reviewed_at"` + ReviewApplied bool `json:"review_applied"` + ApplyAttemptedAt *time.Time `json:"apply_attempted_at,omitempty"` +} + +//go:embed wingman_instruction.md +var wingmanApplyInstruction string + +const ( + wingmanStateFile = ".entire/wingman-state.json" + wingmanReviewFile = ".entire/REVIEW.md" + wingmanLockFile = ".entire/wingman.lock" + + // wingmanStaleReviewTTL is the maximum age of a pending REVIEW.md before + // it's considered stale and automatically cleaned up. + wingmanStaleReviewTTL = 1 * time.Hour +) + +func newWingmanCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "wingman", + Short: "Automated code review for agent sessions", + Long: `Wingman provides automated code review after agent turns. + +When enabled, wingman automatically reviews code changes made by agents, +writes suggestions to .entire/REVIEW.md, and optionally triggers the agent +to apply them.`, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(newWingmanEnableCmd()) + cmd.AddCommand(newWingmanDisableCmd()) + cmd.AddCommand(newWingmanStatusCmd()) + cmd.AddCommand(newWingmanReviewCmd()) + cmd.AddCommand(newWingmanApplyCmd()) + + return cmd +} + +func newWingmanEnableCmd() *cobra.Command { + var ( + useLocal bool + agentFlag string + modelFlag string + ) + + cmd := &cobra.Command{ + Use: "enable", + Short: "Enable wingman auto-review", + RunE: func(cmd *cobra.Command, _ []string) error { + if _, err := paths.RepoRoot(); err != nil { + fmt.Fprintln(cmd.ErrOrStderr(), "Not a git repository. Please run from within a git repository.") + return NewSilentError(errors.New("not a git repository")) + } + + // Load merged settings to check preconditions + merged, err := settings.Load() + if err != nil { + return fmt.Errorf("failed to load settings: %w", err) + } + + if !merged.Enabled { + fmt.Fprintln(cmd.ErrOrStderr(), "Entire is not enabled. Run 'entire enable' first.") + return NewSilentError(errors.New("entire not enabled")) + } + + // Resolve agent + selectedAgent, err := resolveWingmanEnableAgent(cmd, agentFlag) + if err != nil { + return err + } + + // Resolve model + selectedModel, err := resolveWingmanEnableModel(cmd, modelFlag) + if err != nil { + return err + } + + // Load the target file specifically so we don't bloat it with merged values + s, err := loadSettingsTarget(useLocal) + if err != nil { + return fmt.Errorf("failed to load settings: %w", err) + } + + if s.StrategyOptions == nil { + s.StrategyOptions = make(map[string]any) + } + + wingmanOpts := map[string]any{"enabled": true} + if selectedAgent != "" { + wingmanOpts["agent"] = selectedAgent + } + if selectedModel != "" { + wingmanOpts["model"] = selectedModel + } + s.StrategyOptions["wingman"] = wingmanOpts + + if err := saveSettingsTarget(s, useLocal); err != nil { + return fmt.Errorf("failed to save settings: %w", err) + } + + msg := "Wingman enabled." + if selectedAgent != "" { + msg += fmt.Sprintf(" Agent: %s.", selectedAgent) + } + if selectedModel != "" { + msg += fmt.Sprintf(" Model: %s.", selectedModel) + } + msg += " Code changes will be automatically reviewed after agent turns." + if useLocal { + msg += " (saved to settings.local.json)" + } + fmt.Fprintln(cmd.OutOrStdout(), msg) + return nil + }, + } + + cmd.Flags().BoolVar(&useLocal, "local", false, "Write to settings.local.json instead of settings.json") + cmd.Flags().StringVar(&agentFlag, "agent", "", "Agent to use for reviews (e.g., claude-code)") + cmd.Flags().StringVar(&modelFlag, "model", "", "Model to use for reviews (e.g., opus, sonnet)") + + return cmd +} + +// resolveWingmanEnableAgent determines which agent to use for wingman. +// If agentFlag is set, validates it. Otherwise, prompts interactively or uses the default. +func resolveWingmanEnableAgent(cmd *cobra.Command, agentFlag string) (string, error) { + if agentFlag != "" { + // Validate the agent exists and supports Prompter + ag, err := agent.Get(agent.AgentName(agentFlag)) + if err != nil { + return "", fmt.Errorf("unknown agent %q: %w", agentFlag, err) + } + if _, ok := ag.(agent.Prompter); !ok { + return "", fmt.Errorf("agent %q does not support wingman reviews (missing Prompter interface)", agentFlag) + } + return agentFlag, nil + } + + // Find agents that implement Prompter + prompterAgents := findPrompterAgents() + if len(prompterAgents) == 0 { + return "", errors.New("no agents support wingman reviews") + } + + // Single agent — use it automatically + if len(prompterAgents) == 1 { + name := string(prompterAgents[0].Name()) + fmt.Fprintf(cmd.OutOrStdout(), "Using agent: %s\n", prompterAgents[0].Type()) + return name, nil + } + + // Multiple agents — prompt interactively if possible + if !canPromptInteractively() { + name := string(agent.DefaultAgentName) + fmt.Fprintf(cmd.OutOrStdout(), "Using default agent: %s\n", name) + return name, nil + } + + // Build select options + options := make([]huh.Option[string], 0, len(prompterAgents)) + for _, ag := range prompterAgents { + label := fmt.Sprintf("%s (%s)", ag.Type(), ag.Name()) + if ag.Name() == agent.DefaultAgentName { + label += " (default)" + } + options = append(options, huh.NewOption(label, string(ag.Name()))) + } + + var selected string + form := NewAccessibleForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Which agent should wingman use for reviews?"). + Options(options...). + Value(&selected), + ), + ) + if err := form.Run(); err != nil { + return "", fmt.Errorf("agent selection cancelled: %w", err) + } + return selected, nil +} + +// resolveWingmanEnableModel determines which model to use for wingman. +// If modelFlag is set, uses it directly. Otherwise, prompts interactively or uses the default. +func resolveWingmanEnableModel(cmd *cobra.Command, modelFlag string) (string, error) { + if modelFlag != "" { + return modelFlag, nil + } + + if !canPromptInteractively() { + // Non-interactive: use default silently (resolveWingmanModel() handles fallback at runtime) + return "", nil + } + + var selected string + form := NewAccessibleForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Which model should wingman use?"). + Options( + huh.NewOption("opus (default)", "opus"), + huh.NewOption("sonnet", "sonnet"), + huh.NewOption("haiku", "haiku"), + ). + Value(&selected), + ), + ) + if err := form.Run(); err != nil { + return "", fmt.Errorf("model selection cancelled: %w", err) + } + _ = cmd // suppress unused warning in non-interactive path + return selected, nil +} + +// findPrompterAgents returns all registered agents that implement the Prompter interface. +func findPrompterAgents() []agent.Agent { + names := agent.List() + var prompterAgents []agent.Agent + for _, name := range names { + ag, err := agent.Get(name) + if err != nil { + continue + } + if _, ok := ag.(agent.Prompter); ok { + prompterAgents = append(prompterAgents, ag) + } + } + return prompterAgents +} + +func newWingmanDisableCmd() *cobra.Command { + var useLocal bool + + cmd := &cobra.Command{ + Use: "disable", + Short: "Disable wingman auto-review", + RunE: func(cmd *cobra.Command, _ []string) error { + s, err := loadSettingsTarget(useLocal) + if err != nil { + return fmt.Errorf("failed to load settings: %w", err) + } + + if s.StrategyOptions == nil { + s.StrategyOptions = make(map[string]any) + } + s.StrategyOptions["wingman"] = map[string]any{"enabled": false} + + if err := saveSettingsTarget(s, useLocal); err != nil { + return fmt.Errorf("failed to save settings: %w", err) + } + + // Clean up pending review file if it exists + reviewPath, err := paths.AbsPath(wingmanReviewFile) + if err == nil { + _ = os.Remove(reviewPath) + } + + msg := "Wingman disabled." + if useLocal { + msg += " (saved to settings.local.json)" + } + fmt.Fprintln(cmd.OutOrStdout(), msg) + return nil + }, + } + + cmd.Flags().BoolVar(&useLocal, "local", false, "Write to settings.local.json instead of settings.json") + + return cmd +} + +// loadSettingsTarget loads settings from the appropriate file based on the --local flag. +// When local is true, loads from settings.local.json only (without merging). +// When local is false, loads the merged settings (project + local). +func loadSettingsTarget(local bool) (*settings.EntireSettings, error) { + if !local { + s, err := settings.Load() + if err != nil { + return nil, fmt.Errorf("loading settings: %w", err) + } + return s, nil + } + absPath, err := paths.AbsPath(settings.EntireSettingsLocalFile) + if err != nil { + absPath = settings.EntireSettingsLocalFile + } + s, err := settings.LoadFromFile(absPath) + if err != nil { + return nil, fmt.Errorf("loading local settings: %w", err) + } + return s, nil +} + +// saveSettingsTarget saves settings to the appropriate file based on the --local flag. +func saveSettingsTarget(s *settings.EntireSettings, local bool) error { + if local { + if err := settings.SaveLocal(s); err != nil { + return fmt.Errorf("saving local settings: %w", err) + } + return nil + } + if err := settings.Save(s); err != nil { + return fmt.Errorf("saving settings: %w", err) + } + return nil +} + +func newWingmanStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show wingman status", + RunE: func(cmd *cobra.Command, _ []string) error { + s, err := settings.Load() + if err != nil { + return fmt.Errorf("failed to load settings: %w", err) + } + + if s.IsWingmanEnabled() { + fmt.Fprintln(cmd.OutOrStdout(), "Wingman: enabled") + } else { + fmt.Fprintln(cmd.OutOrStdout(), "Wingman: disabled") + } + + // Show configured agent and model + fmt.Fprintf(cmd.OutOrStdout(), "Agent: %s\n", resolveWingmanAgent()) + fmt.Fprintf(cmd.OutOrStdout(), "Model: %s\n", resolveWingmanModel()) + + // Show last review info if available + state, err := loadWingmanState() + if err == nil && state != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Last review: %s\n", state.ReviewedAt.Format(time.RFC3339)) + if state.ReviewApplied { + fmt.Fprintln(cmd.OutOrStdout(), "Status: applied") + } else { + fmt.Fprintln(cmd.OutOrStdout(), "Status: pending") + } + } + + // Check for pending REVIEW.md + reviewPath, err := paths.AbsPath(wingmanReviewFile) + if err == nil { + if _, statErr := os.Stat(reviewPath); statErr == nil { + fmt.Fprintln(cmd.OutOrStdout(), "Pending review: .entire/REVIEW.md") + } + } + + return nil + }, + } +} + +// triggerWingmanReview checks preconditions and spawns the detached review process. +func triggerWingmanReview(payload WingmanPayload) { + // Prevent infinite recursion: if we're inside a wingman auto-apply, + // don't trigger another review. The env var is set by triggerAutoApply. + if os.Getenv("ENTIRE_WINGMAN_APPLY") != "" { + return + } + + logCtx := logging.WithComponent(context.Background(), "wingman") + repoRoot := payload.RepoRoot + + totalFiles := len(payload.ModifiedFiles) + len(payload.NewFiles) + len(payload.DeletedFiles) + logging.Info(logCtx, "wingman trigger evaluating", + slog.String("session_id", payload.SessionID), + slog.Int("file_count", totalFiles), + ) + + // Check if a pending REVIEW.md already exists for the current session + if shouldSkipPendingReview(repoRoot, payload.SessionID) { + logging.Info(logCtx, "wingman skipped: pending review exists for current session") + fmt.Fprintf(os.Stderr, "[wingman] Pending review exists, skipping\n") + return + } + + // Atomic lock file prevents concurrent review spawns. O_CREATE|O_EXCL + // is atomic on all platforms, avoiding the TOCTOU race of Stat+WriteFile. + lockPath := filepath.Join(repoRoot, wingmanLockFile) + if !acquireWingmanLock(lockPath, payload.SessionID) { + logging.Info(logCtx, "wingman skipped: review already in progress") + fmt.Fprintf(os.Stderr, "[wingman] Review in progress, skipping\n") + return + } + + // Dedup check: compute hash of sorted file paths + allFiles := make([]string, 0, len(payload.ModifiedFiles)+len(payload.NewFiles)+len(payload.DeletedFiles)) + allFiles = append(allFiles, payload.ModifiedFiles...) + allFiles = append(allFiles, payload.NewFiles...) + allFiles = append(allFiles, payload.DeletedFiles...) + filesHash := computeFilesHash(allFiles) + + state, _ := loadWingmanState() //nolint:errcheck // best-effort dedup + if state != nil && state.FilesHash == filesHash && state.SessionID == payload.SessionID { + logging.Info(logCtx, "wingman skipped: dedup hash match", + slog.String("files_hash", filesHash[:12]), + ) + fmt.Fprintf(os.Stderr, "[wingman] Already reviewed these changes, skipping\n") + return + } + + // Capture HEAD at trigger time so the detached review diffs against + // the correct commit even if HEAD moves during the initial delay. + payload.BaseCommit = resolveHEAD(repoRoot) + logging.Debug(logCtx, "wingman captured base commit", + slog.String("base_commit", payload.BaseCommit), + ) + + // Write payload to a temp file instead of passing as a CLI argument, + // which can exceed OS argv limits (~128KB Linux, ~256KB macOS) with + // many files or long prompts. + payloadJSON, err := json.Marshal(payload) + if err != nil { + logging.Error(logCtx, "wingman failed to marshal payload", slog.Any("error", err)) + fmt.Fprintf(os.Stderr, "[wingman] Failed to marshal payload: %v\n", err) + _ = os.Remove(lockPath) + return + } + payloadPath := filepath.Join(repoRoot, ".entire", "wingman-payload.json") + //nolint:gosec // G306: payload file is not secrets + if err := os.WriteFile(payloadPath, payloadJSON, 0o644); err != nil { + logging.Error(logCtx, "wingman failed to write payload file", slog.Any("error", err)) + fmt.Fprintf(os.Stderr, "[wingman] Failed to write payload file: %v\n", err) + _ = os.Remove(lockPath) + return + } + + // Spawn detached review process with path to payload file + spawnDetachedWingmanReview(repoRoot, payloadPath) + logging.Info(logCtx, "wingman review spawned", + slog.String("session_id", payload.SessionID), + slog.String("base_commit", payload.BaseCommit), + slog.Int("file_count", totalFiles), + ) + fmt.Fprintf(os.Stderr, "[wingman] Review starting in background...\n") +} + +// triggerWingmanFromCommit builds a wingman payload from the HEAD commit and +// triggers a review. Used by the git post-commit hook for manual-commit strategy +// where files are committed by the user (not by SaveChanges). +func triggerWingmanFromCommit() { + // Prevent infinite recursion: skip if inside wingman auto-apply + if os.Getenv("ENTIRE_WINGMAN_APPLY") != "" { + return + } + if !settings.IsWingmanEnabled() { + return + } + + repoRoot, err := paths.RepoRoot() + if err != nil { + return + } + + head := resolveHEAD(repoRoot) + if head == "" { + return + } + + // Get changed files from the commit + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + //nolint:gosec // G204: head is from git rev-parse, not user input + cmd := exec.CommandContext(ctx, "git", "diff-tree", "--no-commit-id", "--name-status", "-r", head) + cmd.Dir = repoRoot + out, err := cmd.Output() + if err != nil || len(out) == 0 { + return + } + + var modified, newFiles, deleted []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if len(line) < 3 { + continue + } + status := line[0] + file := strings.TrimSpace(line[1:]) + switch status { + case 'M': + modified = append(modified, file) + case 'A': + newFiles = append(newFiles, file) + case 'D': + deleted = append(deleted, file) + } + } + + if len(modified)+len(newFiles)+len(deleted) == 0 { + return + } + + // Get commit message + //nolint:gosec // G204: head is from git rev-parse, not user input + msgCmd := exec.CommandContext(ctx, "git", "log", "-1", "--format=%B", head) + msgCmd.Dir = repoRoot + msgOut, _ := msgCmd.Output() //nolint:errcheck // best-effort commit message + commitMessage := strings.TrimSpace(string(msgOut)) + + sessionID := strategy.FindMostRecentSession() + + triggerWingmanReview(WingmanPayload{ + SessionID: sessionID, + RepoRoot: repoRoot, + ModifiedFiles: modified, + NewFiles: newFiles, + DeletedFiles: deleted, + CommitMessage: commitMessage, + }) +} + +// staleLockThreshold is how old a lock file can be before we consider it stale +// (e.g., the detached process was SIGKILLed and the defer never ran). +const staleLockThreshold = 30 * time.Minute + +// acquireWingmanLock atomically creates the lock file. Returns true if acquired. +// If the lock already exists but is older than staleLockThreshold, it is removed +// and re-acquired (handles crashed detached processes). +func acquireWingmanLock(lockPath, sessionID string) bool { + //nolint:gosec // G304: lockPath is constructed from repoRoot + constant + f, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + if !errors.Is(err, os.ErrExist) { + fmt.Fprintf(os.Stderr, "[wingman] Failed to create lock: %v\n", err) + return false + } + // Lock exists — check if it's stale + info, statErr := os.Stat(lockPath) + if statErr != nil || time.Since(info.ModTime()) <= staleLockThreshold { + fmt.Fprintf(os.Stderr, "[wingman] Review already in progress, skipping\n") + return false + } + fmt.Fprintf(os.Stderr, "[wingman] Removing stale lock (age: %s)\n", + time.Since(info.ModTime()).Round(time.Second)) + _ = os.Remove(lockPath) + // Retry the atomic create + //nolint:gosec // G304: lockPath is constructed from repoRoot + constant + f, err = os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + fmt.Fprintf(os.Stderr, "[wingman] Failed to create lock after stale removal: %v\n", err) + return false + } + } + _, _ = f.WriteString(sessionID) //nolint:errcheck // best-effort session ID write + _ = f.Close() + return true +} + +// resolveHEAD returns the current HEAD commit hash, or empty string on error. +func resolveHEAD(repoRoot string) string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD") + cmd.Dir = repoRoot + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// computeFilesHash returns a SHA256 hex digest of the sorted file paths. +// Uses null byte separator (impossible in filenames) to avoid ambiguity. +func computeFilesHash(files []string) string { + sorted := make([]string, len(files)) + copy(sorted, files) + sort.Strings(sorted) + h := sha256.Sum256([]byte(strings.Join(sorted, "\x00"))) + return hex.EncodeToString(h[:]) +} + +// loadWingmanState loads the wingman state from .entire/wingman-state.json. +func loadWingmanState() (*WingmanState, error) { + statePath, err := paths.AbsPath(wingmanStateFile) + if err != nil { + return nil, fmt.Errorf("resolving wingman state path: %w", err) + } + + data, err := os.ReadFile(statePath) //nolint:gosec // path is repo-relative + if err != nil { + return nil, fmt.Errorf("reading wingman state: %w", err) + } + + var state WingmanState + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("parsing wingman state: %w", err) + } + return &state, nil +} + +// saveWingmanState saves the wingman state to .entire/wingman-state.json. +func saveWingmanState(state *WingmanState) error { + statePath, err := paths.AbsPath(wingmanStateFile) + if err != nil { + return fmt.Errorf("resolving wingman state path: %w", err) + } + + dir := filepath.Dir(statePath) + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("creating wingman state directory: %w", err) + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("marshaling wingman state: %w", err) + } + + //nolint:gosec // G306: state file is config, not secrets + if err := os.WriteFile(statePath, data, 0o644); err != nil { + return fmt.Errorf("writing wingman state: %w", err) + } + return nil +} + +// loadWingmanStateDirect reads the wingman state file directly from a known +// path under repoRoot. Returns nil if the file doesn't exist or can't be parsed. +func loadWingmanStateDirect(repoRoot string) *WingmanState { + statePath := filepath.Join(repoRoot, wingmanStateFile) + data, err := os.ReadFile(statePath) //nolint:gosec // path is repo-relative constant + if err != nil { + return nil + } + var state WingmanState + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + return &state +} + +// newWingmanApplyCmd returns a hidden subcommand used by the stop hook to +// apply a pending REVIEW.md via claude --continue in a detached subprocess. +func newWingmanApplyCmd() *cobra.Command { + return &cobra.Command{ + Use: "__apply", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runWingmanApply(args[0]) + }, + } +} + +// shouldSkipPendingReview checks whether a pending REVIEW.md should prevent +// a new review from being triggered. It cleans up stale/orphaned reviews. +// +// Returns true only when REVIEW.md exists AND belongs to the current session +// AND is younger than wingmanStaleReviewTTL. +func shouldSkipPendingReview(repoRoot, currentSessionID string) bool { + reviewPath := filepath.Join(repoRoot, wingmanReviewFile) + if _, err := os.Stat(reviewPath); err != nil { + return false // No REVIEW.md, don't skip + } + + // REVIEW.md exists — check state to determine if it's current or stale + state := loadWingmanStateDirect(repoRoot) + if state == nil { + // Orphan: REVIEW.md without state file — clean up + _ = os.Remove(reviewPath) + return false + } + + if state.SessionID != currentSessionID { + // Different session — stale review, clean up + _ = os.Remove(reviewPath) + return false + } + + if time.Since(state.ReviewedAt) > wingmanStaleReviewTTL { + // Same session but too old — clean up + _ = os.Remove(reviewPath) + return false + } + + return true // Current session, fresh review — skip +} diff --git a/cmd/entire/cli/wingman_instruction.md b/cmd/entire/cli/wingman_instruction.md new file mode 100644 index 000000000..d960c5e66 --- /dev/null +++ b/cmd/entire/cli/wingman_instruction.md @@ -0,0 +1,9 @@ +IMPORTANT — MANDATORY PRE-STEP: A code review has been generated at `.entire/REVIEW.md`. You MUST address it BEFORE responding to the user's request. + +Steps (do these FIRST, before anything else): +1. Read `.entire/REVIEW.md` +2. Address each suggestion you agree with — be critical, skip anything you disagree with +3. Delete `.entire/REVIEW.md` when done +4. Briefly tell the user what you changed from the review + +Then proceed with the user's request. diff --git a/cmd/entire/cli/wingman_lifecycle.go b/cmd/entire/cli/wingman_lifecycle.go new file mode 100644 index 000000000..e7ddda8f0 --- /dev/null +++ b/cmd/entire/cli/wingman_lifecycle.go @@ -0,0 +1,229 @@ +// wingman_lifecycle.go contains wingman integration hooks for the lifecycle dispatcher. +// These functions are called from lifecycle.go event handlers to inject wingman +// review state into session start banners, turn start context, and turn end actions. +package cli + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// wingmanNotificationLockThreshold is the maximum lock file age for showing +// "Reviewing your changes..." notifications. Much tighter than staleLockThreshold +// (used for lock acquisition) because a real review takes at most +// wingmanInitialDelay (10s) + wingmanReviewTimeout (5m) ≈ 6 minutes. +// A lock older than this is almost certainly stale for notification purposes. +const wingmanNotificationLockThreshold = 10 * time.Minute + +// appendWingmanSessionStartStatus appends wingman status to the session start banner. +// Returns the message with wingman status appended. +func appendWingmanSessionStartStatus(message string) string { + if !settings.IsWingmanEnabled() { + return message + } + + repoRoot, rootErr := paths.RepoRoot() + if rootErr != nil { + return message + "\n Wingman is active: your changes will be automatically reviewed." + } + + reviewPath := filepath.Join(repoRoot, wingmanReviewFile) + if _, statErr := os.Stat(reviewPath); statErr == nil && reviewHasActionableIssues(reviewPath) { + return message + "\n Wingman: a review is pending and will be addressed on your next prompt." + } + return message + "\n Wingman is active: your changes will be automatically reviewed." +} + +// handleWingmanTurnStart checks for a pending wingman review and injects it +// as additionalContext so the agent addresses it before the user's request. +// Returns true if a hook response was written to stdout (caller should return early). +func handleWingmanTurnStart(sessionID string) bool { + if !settings.IsWingmanEnabled() { + return false + } + + repoRoot, rootErr := paths.RepoRoot() + if rootErr != nil { + return false + } + + wingmanLogCtx := logging.WithComponent(context.Background(), "wingman") + + // If a review is pending, check if it has actionable issues before injecting + reviewPath := filepath.Join(repoRoot, wingmanReviewFile) + if _, statErr := os.Stat(reviewPath); statErr == nil { + if !reviewHasActionableIssues(reviewPath) { + // No actionable issues — clean up and notify the user + fmt.Fprintf(os.Stderr, "[wingman] Review has no actionable issues, cleaning up\n") + logging.Info(wingmanLogCtx, "wingman review has no actionable issues, skipping injection", + slog.String("session_id", sessionID), + ) + _ = os.Remove(reviewPath) + _ = outputHookMessage("[Wingman] Reviewed your changes — no issues found.") //nolint:errcheck // best-effort notification + } else { + fmt.Fprintf(os.Stderr, "[wingman] Review available: .entire/REVIEW.md — injecting into context\n") + logging.Info(wingmanLogCtx, "wingman injecting review instruction on prompt-submit", + slog.String("session_id", sessionID), + ) + if err := outputHookResponseWithContextAndMessage( + wingmanApplyInstruction, + "[Wingman] A code review is pending and will be addressed before your request.", + ); err != nil { + fmt.Fprintf(os.Stderr, "[wingman] Warning: failed to inject review instruction: %v\n", err) + } else { + return true // Hook response written to stdout — caller must return + } + } + } + + // Notify if a review is currently in progress (fresh lock file). + lockPath := filepath.Join(repoRoot, wingmanLockFile) + if lockInfo, statErr := os.Stat(lockPath); statErr == nil && time.Since(lockInfo.ModTime()) <= wingmanNotificationLockThreshold { + logging.Info(wingmanLogCtx, "wingman review in progress", + slog.String("session_id", sessionID), + ) + if err := outputHookMessage("[Wingman] Review in progress..."); err != nil { + fmt.Fprintf(os.Stderr, "[wingman] Warning: failed to output review-in-progress message: %v\n", err) + } + } + + return false +} + +// handleWingmanTurnEnd handles wingman actions at turn end: triggering reviews, +// auto-applying pending reviews, and showing stop notifications. +func handleWingmanTurnEnd(sessionID string, totalChanges int, relModifiedFiles, relNewFiles, relDeletedFiles, allPrompts []string, commitMessage string) { + repoRoot, err := paths.RepoRoot() + if err != nil { + return + } + + strat := GetStrategy() + + // Trigger wingman review for auto-commit strategy (commit already happened + // in SaveStep). Manual-commit triggers wingman from the git post-commit hook + // instead, since the user commits manually. + if totalChanges > 0 && strat.Name() == strategy.StrategyNameAutoCommit && settings.IsWingmanEnabled() { + triggerWingmanReview(WingmanPayload{ + SessionID: sessionID, + RepoRoot: repoRoot, + ModifiedFiles: relModifiedFiles, + NewFiles: relNewFiles, + DeletedFiles: relDeletedFiles, + Prompts: allPrompts, + CommitMessage: commitMessage, + }) + } + + // Auto-apply pending wingman review on turn end + triggerWingmanAutoApplyIfPending(repoRoot) + + outputWingmanStopNotification(repoRoot) +} + +// triggerWingmanAutoApplyIfPending checks for a pending REVIEW.md and spawns +// the auto-apply subprocess if conditions are met. Called from the stop hook +// on every turn end (both with-changes and no-changes paths). +// +// When a live session exists, this is a no-op: the prompt-submit injection +// will deliver the review visibly in the user's terminal instead. Background +// auto-apply is only used when no sessions are alive (all ended). +func triggerWingmanAutoApplyIfPending(repoRoot string) { + logCtx := logging.WithComponent(context.Background(), "wingman") + if !settings.IsWingmanEnabled() { + logging.Debug(logCtx, "wingman auto-apply skip: wingman not enabled") + return + } + if os.Getenv("ENTIRE_WINGMAN_APPLY") != "" { + logging.Debug(logCtx, "wingman auto-apply skip: already in apply subprocess") + return + } + reviewPath := filepath.Join(repoRoot, wingmanReviewFile) + if _, statErr := os.Stat(reviewPath); statErr != nil { + logging.Debug(logCtx, "wingman auto-apply skip: no REVIEW.md pending") + return + } + // Skip auto-apply if the review has no actionable issues + if !reviewHasActionableIssues(reviewPath) { + logging.Info(logCtx, "wingman auto-apply skip: review has no actionable issues, cleaning up") + _ = os.Remove(reviewPath) + return + } + wingmanState := loadWingmanStateDirect(repoRoot) + if wingmanState != nil && wingmanState.ApplyAttemptedAt != nil { + logging.Debug(logCtx, "wingman auto-apply already attempted, skipping", + slog.Time("attempted_at", *wingmanState.ApplyAttemptedAt), + ) + return + } + // Don't spawn background auto-apply if a live session exists. + // The prompt-submit hook will inject REVIEW.md as additionalContext, + // which is visible to the user in their terminal. + if hasAnyLiveSession(repoRoot) { + logging.Debug(logCtx, "wingman auto-apply deferred: live session will handle via injection") + fmt.Fprintf(os.Stderr, "[wingman] Review pending — will be injected on next prompt\n") + return + } + fmt.Fprintf(os.Stderr, "[wingman] Pending review found, spawning auto-apply (no live sessions)\n") + logging.Info(logCtx, "wingman auto-apply spawning (no live sessions)", + slog.String("review_path", reviewPath), + ) + spawnDetachedWingmanApply(repoRoot) +} + +// reviewHasActionableIssues reads a REVIEW.md and checks if it contains +// actionable issue markers ([CRITICAL], [WARNING], or [SUGGESTION]). +// Returns false if the file can't be read or contains no issues. +func reviewHasActionableIssues(reviewPath string) bool { + data, err := os.ReadFile(reviewPath) //nolint:gosec // path is from repo-relative constant + if err != nil { + return false + } + + content := string(data) + + // The review format uses "### [SEVERITY] description" for each issue. + // Check for any of the severity markers in heading context. + for _, marker := range []string{"[CRITICAL]", "[WARNING]", "[SUGGESTION]"} { + if strings.Contains(content, marker) { + return true + } + } + + return false +} + +// outputWingmanStopNotification outputs a systemMessage notification about +// wingman status at the end of a stop hook. This makes wingman activity visible +// in the agent terminal without injecting context into the agent's conversation. +// Best-effort: status may be stale due to concurrent wingman processes. +func outputWingmanStopNotification(repoRoot string) { + if !settings.IsWingmanEnabled() { + return + } + if os.Getenv("ENTIRE_WINGMAN_APPLY") != "" { + return + } + + lockPath := filepath.Join(repoRoot, wingmanLockFile) + if info, err := os.Stat(lockPath); err == nil && time.Since(info.ModTime()) <= wingmanNotificationLockThreshold { + _ = outputHookMessage("[Wingman] Reviewing your changes...") //nolint:errcheck // best-effort notification + return + } + + reviewPath := filepath.Join(repoRoot, wingmanReviewFile) + if _, err := os.Stat(reviewPath); err == nil && reviewHasActionableIssues(reviewPath) { + _ = outputHookMessage("[Wingman] Review pending \u2014 will be addressed on your next prompt") //nolint:errcheck // best-effort notification + return + } +} diff --git a/cmd/entire/cli/wingman_prompt.go b/cmd/entire/cli/wingman_prompt.go new file mode 100644 index 000000000..50701385a --- /dev/null +++ b/cmd/entire/cli/wingman_prompt.go @@ -0,0 +1,140 @@ +package cli + +import ( + "fmt" + "strings" +) + +// maxDiffSize is the maximum size of the diff included in the review prompt. +// Large diffs degrade review quality, so we truncate. +const maxDiffSize = 100 * 1024 // 100KB + +// reviewPromptTemplate is the prompt sent to Claude for code review. +// +// Security note: User content (diff, prompts, context) is wrapped in XML tags +// to provide clear boundary markers, similar to the summarization prompt pattern. +const reviewPromptTemplate = `You are a senior code reviewer performing an intent-aware review. Your job is not just to find bugs in the code — it is to evaluate whether the changes correctly and completely fulfill what the developer was trying to accomplish. + +## Session Context + +This review is part of an automated development workflow. The developer works with an AI coding agent that makes changes on their behalf. Below is the checkpoint data captured during the session, which tells you WHY these changes were made. + +### Developer's Prompts +The original instructions the developer gave to the agent: + +%s + + +### Commit Message +%s + +### Session Context +Checkpoint data including the session summary and key actions taken: + +%s + + +### Checkpoint Files +You have read-only access to the repository. If you need deeper context about the session, +you can read these checkpoint files (they may or may not exist): +- ` + "`%s`" + ` — session transcript (JSONL format) +- ` + "`%s`" + ` — user prompts collected during session +- ` + "`%s`" + ` — generated session context/summary + +## Code Changes + +Files changed: %s + + +%s + + +## Review Instructions + +Use the session context above to understand the developer's intent. Then review the code changes with that intent in mind. + +**Intent alignment** (most important): +- Do the changes actually accomplish what the developer asked for? +- Are there any prompts or requirements that were missed or only partially implemented? +- Does the implementation match the stated approach in the session context? + +**Correctness**: +- Bugs, logic errors, or off-by-one mistakes +- Race conditions or concurrency issues +- Missing error handling for failure paths that matter + +**Security**: +- Injection vulnerabilities (SQL, command, XSS) +- Hardcoded secrets or credentials +- Unsafe file operations or path traversal + +**Robustness**: +- Edge cases not handled (empty inputs, nil pointers, large data) +- Resource leaks (unclosed files, connections, goroutines) +- Missing timeouts on external calls + +Do NOT flag: +- Style preferences or formatting (the linter handles that) +- Missing comments or documentation on clear code +- Theoretical issues that cannot happen given the actual call sites + +For each issue found, provide: +1. Severity: CRITICAL, WARNING, or SUGGESTION +2. File path and approximate line reference (from the diff) +3. Description of the issue +4. Suggested fix (code snippet when helpful) + +Format your response as Markdown: + +# Code Review + +## Summary +Brief assessment: does this change accomplish its stated goal? What's the overall quality? + +## Issues + +### [SEVERITY] Short description +**File:** ` + "`path/to/file.go:42`" + ` +**Description:** What the issue is and why it matters. +**Suggestion:** +` + "```" + ` +// suggested fix +` + "```" + ` + +If no issues are found, confirm the changes look correct and match the intent. +Do NOT include any preamble or explanation outside the Markdown structure above.` + +// buildReviewPrompt constructs the review prompt from the payload, context, and diff. +func buildReviewPrompt(prompts []string, commitMessage, sessionContext, sessionID, fileList, diff string) string { + promptText := strings.Join(prompts, "\n\n---\n\n") + if promptText == "" { + promptText = "(no prompts captured)" + } + + commitMsgText := commitMessage + if commitMsgText == "" { + commitMsgText = "(no commit message)" + } + + contextText := sessionContext + if contextText == "" { + contextText = "(no session context available)" + } + + // Build checkpoint file paths + metadataDir := ".entire/metadata/" + sessionID + if sessionID == "" { + metadataDir = ".entire/metadata/" + } + transcriptPath := metadataDir + "/full.jsonl" + promptPath := metadataDir + "/prompt.txt" + contextPath := metadataDir + "/context.md" + + // Truncate large diffs + if len(diff) > maxDiffSize { + diff = diff[:maxDiffSize] + "\n\n... (diff truncated at 100KB)" + } + + return fmt.Sprintf(reviewPromptTemplate, promptText, commitMsgText, contextText, + transcriptPath, promptPath, contextPath, fileList, diff) +} diff --git a/cmd/entire/cli/wingman_review.go b/cmd/entire/cli/wingman_review.go new file mode 100644 index 000000000..39bd3d128 --- /dev/null +++ b/cmd/entire/cli/wingman_review.go @@ -0,0 +1,584 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/spf13/cobra" +) + +// wingmanLog writes a timestamped log line to stderr. In the detached subprocess, +// stderr is redirected to .entire/logs/wingman.log by the spawner. +func wingmanLog(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + fmt.Fprintf(os.Stderr, "%s [wingman] %s\n", time.Now().Format(time.RFC3339), msg) +} + +const ( + // wingmanInitialDelay is how long to wait before starting the review, + // letting the agent turn fully settle. + wingmanInitialDelay = 10 * time.Second + + // wingmanDefaultReviewModel is the default model used for wingman reviews. + // Can be overridden via strategy_options.wingman.model in settings. + wingmanDefaultReviewModel = "opus" + + // wingmanGitTimeout is the timeout for git diff operations. + wingmanGitTimeout = 60 * time.Second + + // wingmanReviewTimeout is the timeout for the claude --print review call. + wingmanReviewTimeout = 5 * time.Minute + + // wingmanApplyTimeout is the timeout for the claude --continue auto-apply call. + wingmanApplyTimeout = 15 * time.Minute +) + +func newWingmanReviewCmd() *cobra.Command { + return &cobra.Command{ + Use: "__review", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runWingmanReview(args[0]) + }, + } +} + +func runWingmanReview(payloadPath string) error { + wingmanLog("review process started (pid=%d)", os.Getpid()) + wingmanLog("reading payload from %s", payloadPath) + + // Read payload from file (avoids OS argv limits with large payloads) + payloadJSON, err := os.ReadFile(payloadPath) //nolint:gosec // path is from our own spawn + if err != nil { + wingmanLog("ERROR reading payload: %v", err) + return fmt.Errorf("failed to read payload file: %w", err) + } + // Clean up payload file after reading + _ = os.Remove(payloadPath) + + var payload WingmanPayload + if err := json.Unmarshal(payloadJSON, &payload); err != nil { + wingmanLog("ERROR unmarshaling payload: %v", err) + return fmt.Errorf("failed to unmarshal payload: %w", err) + } + + repoRoot := payload.RepoRoot + if repoRoot == "" { + wingmanLog("ERROR repo_root is empty in payload") + return errors.New("repo_root is required in payload") + } + + totalFiles := len(payload.ModifiedFiles) + len(payload.NewFiles) + len(payload.DeletedFiles) + wingmanLog("session=%s repo=%s base_commit=%s files=%d", + payload.SessionID, repoRoot, payload.BaseCommit, totalFiles) + + // Clean up lock file when review completes (regardless of success/failure) + lockPath := filepath.Join(repoRoot, wingmanLockFile) + defer func() { + if err := os.Remove(lockPath); err != nil && !errors.Is(err, os.ErrNotExist) { + wingmanLog("WARNING: failed to remove lock file: %v", err) + } else { + wingmanLog("lock file removed") + } + }() + + // Initial delay: let the agent turn fully settle + wingmanLog("waiting %s for agent turn to settle", wingmanInitialDelay) + time.Sleep(wingmanInitialDelay) + + // Compute diff using the base commit captured at trigger time + wingmanLog("computing diff (merge-base with main/master)") + diffStart := time.Now() + diff, err := computeDiff(repoRoot) + if err != nil { + wingmanLog("ERROR computing diff: %v", err) + return fmt.Errorf("failed to compute diff: %w", err) + } + + if strings.TrimSpace(diff) == "" { + wingmanLog("no changes found in diff, exiting") + return nil // No changes to review + } + wingmanLog("diff computed: %d bytes in %s", len(diff), time.Since(diffStart).Round(time.Millisecond)) + + // Build file list for the prompt + allFiles := make([]string, 0, len(payload.ModifiedFiles)+len(payload.NewFiles)+len(payload.DeletedFiles)) + for _, f := range payload.ModifiedFiles { + allFiles = append(allFiles, f+" (modified)") + } + for _, f := range payload.NewFiles { + allFiles = append(allFiles, f+" (new)") + } + for _, f := range payload.DeletedFiles { + allFiles = append(allFiles, f+" (deleted)") + } + fileList := strings.Join(allFiles, ", ") + + // Read session context from checkpoint data (best-effort) + sessionContext := readSessionContext(repoRoot, payload.SessionID) + if sessionContext != "" { + wingmanLog("session context loaded: %d bytes", len(sessionContext)) + } + + // Build review prompt + prompt := buildReviewPrompt(payload.Prompts, payload.CommitMessage, sessionContext, payload.SessionID, fileList, diff) + wingmanLog("review prompt built: %d bytes", len(prompt)) + + // Call agent for review + model := resolveWingmanModel() + wingmanLog("calling agent for review (model=%s, timeout=%s)", model, wingmanReviewTimeout) + reviewStart := time.Now() + reviewText, err := callAgentForReview(repoRoot, prompt, model) + if err != nil { + wingmanLog("ERROR agent review failed after %s: %v", time.Since(reviewStart).Round(time.Millisecond), err) + return fmt.Errorf("failed to get review from agent: %w", err) + } + wingmanLog("review received: %d bytes in %s", len(reviewText), time.Since(reviewStart).Round(time.Millisecond)) + + // Write REVIEW.md + reviewPath := filepath.Join(repoRoot, wingmanReviewFile) + if err := os.MkdirAll(filepath.Dir(reviewPath), 0o750); err != nil { + wingmanLog("ERROR creating directory: %v", err) + return fmt.Errorf("failed to create directory: %w", err) + } + //nolint:gosec // G306: review file is not secrets + if err := os.WriteFile(reviewPath, []byte(reviewText), 0o644); err != nil { + wingmanLog("ERROR writing REVIEW.md: %v", err) + return fmt.Errorf("failed to write REVIEW.md: %w", err) + } + wingmanLog("REVIEW.md written to %s", reviewPath) + + // Update dedup state — write directly to known path instead of using + // os.Chdir (which mutates process-wide state). + allFilePaths := make([]string, 0, len(payload.ModifiedFiles)+len(payload.NewFiles)+len(payload.DeletedFiles)) + allFilePaths = append(allFilePaths, payload.ModifiedFiles...) + allFilePaths = append(allFilePaths, payload.NewFiles...) + allFilePaths = append(allFilePaths, payload.DeletedFiles...) + + saveWingmanStateDirect(repoRoot, &WingmanState{ + SessionID: payload.SessionID, + FilesHash: computeFilesHash(allFilePaths), + ReviewedAt: time.Now(), + ReviewApplied: false, + }) + wingmanLog("dedup state saved") + + // If any session is live (IDLE/ACTIVE/ACTIVE_COMMITTED), don't auto-apply + // in the background. The prompt-submit hook will inject REVIEW.md as + // additionalContext when the user sends their next prompt — this is VISIBLE + // in their terminal. Only use background auto-apply when no sessions are + // alive (e.g., user closed all sessions). + if hasAnyLiveSession(repoRoot) { + wingmanLog("live session detected, deferring to prompt-submit injection (visible)") + return nil + } + + // No live sessions — apply in background as fallback + wingmanLog("no live sessions, triggering background auto-apply") + now := time.Now() + state := loadWingmanStateDirect(repoRoot) + if state != nil { + state.ApplyAttemptedAt = &now + saveWingmanStateDirect(repoRoot, state) + } + + applyStart := time.Now() + if err := triggerAutoApply(repoRoot); err != nil { + wingmanLog("ERROR auto-apply failed after %s: %v", time.Since(applyStart).Round(time.Millisecond), err) + return fmt.Errorf("failed to trigger auto-apply: %w", err) + } + wingmanLog("auto-apply completed in %s", time.Since(applyStart).Round(time.Millisecond)) + + return nil +} + +// computeDiff gets the full branch diff for review. It diffs the current HEAD +// against the merge base with the default branch (main/master), giving the +// reviewer a holistic view of all branch changes rather than just one commit. +func computeDiff(repoRoot string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), wingmanGitTimeout) + defer cancel() + + // Find the merge base with the default branch for a holistic branch diff. + mergeBase := findMergeBase(ctx, repoRoot) + if mergeBase != "" { + wingmanLog("using merge-base %s for branch diff", mergeBase) + diff, err := gitDiff(ctx, repoRoot, mergeBase) + if err == nil && strings.TrimSpace(diff) != "" { + return diff, nil + } + // Fall through to HEAD diff if merge-base diff fails or is empty + } + + // Fallback: diff uncommitted changes against HEAD + wingmanLog("falling back to HEAD diff") + diff, err := gitDiff(ctx, repoRoot, "HEAD") + if err != nil { + return "", fmt.Errorf("git diff failed: %w", err) + } + + // If no uncommitted changes, try the latest commit itself + if strings.TrimSpace(diff) == "" { + diff, err = gitDiff(ctx, repoRoot, "HEAD~1", "HEAD") + if err != nil { + return "", fmt.Errorf("git diff for latest commit failed: %w", err) + } + } + + return diff, nil +} + +// findMergeBase returns the merge-base commit between HEAD and the default +// branch (tries main, then master). Returns empty string if not found. +func findMergeBase(ctx context.Context, repoRoot string) string { + for _, branch := range []string{"main", "master"} { + cmd := exec.CommandContext(ctx, "git", "merge-base", branch, "HEAD") //nolint:gosec // branch is from hardcoded slice + cmd.Dir = repoRoot + out, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)) + } + } + return "" +} + +// gitDiff runs git diff with the given args and returns stdout. +func gitDiff(ctx context.Context, repoRoot string, args ...string) (string, error) { + fullArgs := append([]string{"diff"}, args...) + cmd := exec.CommandContext(ctx, "git", fullArgs...) //nolint:gosec // args are from internal logic + cmd.Dir = repoRoot + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git diff %v: %w (stderr: %s)", args, err, stderr.String()) + } + return stdout.String(), nil +} + +// resolveWingmanModel returns the model to use for wingman reviews, +// reading from settings with a fallback to the default. +func resolveWingmanModel() string { + s, err := settings.Load() + if err != nil { + return wingmanDefaultReviewModel + } + if m := s.WingmanModel(); m != "" { + return m + } + return wingmanDefaultReviewModel +} + +// resolveWingmanAgent returns the agent name to use for wingman reviews, +// reading from settings with a fallback to the default agent. +func resolveWingmanAgent() agent.AgentName { + s, err := settings.Load() + if err != nil { + return agent.DefaultAgentName + } + if a := s.WingmanAgent(); a != "" { + return agent.AgentName(a) + } + return agent.DefaultAgentName +} + +// callAgentForReview invokes the configured agent's Prompter to perform a review. +// repoRoot is the repository root so the reviewer can access the full codebase. +func callAgentForReview(repoRoot, prompt, model string) (string, error) { + agentName := resolveWingmanAgent() + + ag, err := agent.Get(agentName) + if err != nil { + return "", fmt.Errorf("wingman agent %q not found: %w", agentName, err) + } + + prompter, ok := ag.(agent.Prompter) + if !ok { + return "", fmt.Errorf("agent %q does not support prompting (does not implement agent.Prompter)", agentName) + } + + ctx, cancel := context.WithTimeout(context.Background(), wingmanReviewTimeout) + defer cancel() + + // Grant read-only tool access so the reviewer can inspect source files + // beyond just the diff. Permission bypass is safe here since tools are + // restricted to read-only operations. + result, err := prompter.Prompt(ctx, prompt, agent.PromptOptions{ + Model: model, + WorkDir: repoRoot, + AllowedTools: "Read,Glob,Grep", + PermissionMode: "bypassPermissions", + }) + if err != nil { + return "", fmt.Errorf("agent review prompt failed: %w", err) + } + + return result.Text, nil +} + +// readSessionContext reads the context.md file from the session's checkpoint +// metadata directory. Returns empty string if unavailable. +func readSessionContext(repoRoot, sessionID string) string { + if sessionID == "" { + return "" + } + contextPath := filepath.Join(repoRoot, ".entire", "metadata", sessionID, "context.md") + data, err := os.ReadFile(contextPath) //nolint:gosec // path constructed from repoRoot + session ID + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + wingmanLog("WARNING: failed to read session context: %v", err) + } + return "" + } + return string(data) +} + +// staleActiveSessionThreshold is the maximum age of a session state file in an +// ACTIVE phase before it is considered stale (crashed agent) and ignored by +// hasAnyLiveSession. Only applies to ACTIVE/ACTIVE_COMMITTED phases — an IDLE +// session is always considered live regardless of age (user may just be away). +const staleActiveSessionThreshold = 4 * time.Hour + +// hasAnyLiveSession checks if any session is in a non-ENDED phase (IDLE, +// ACTIVE, or ACTIVE_COMMITTED). Used to decide whether to defer review +// application to the prompt-submit injection (visible to user) vs background +// auto-apply (invisible). +// +// ACTIVE/ACTIVE_COMMITTED sessions older than staleActiveSessionThreshold are +// skipped as likely orphaned from crashed processes. IDLE sessions are always +// considered live regardless of age. +func hasAnyLiveSession(repoRoot string) bool { + sessDir := findSessionStateDir(repoRoot) + if sessDir == "" { + return false + } + + entries, err := os.ReadDir(sessDir) + if err != nil { + return false + } + + const maxCheck = 50 + checked := 0 + for _, entry := range entries { + if checked >= maxCheck { + wingmanLog("stopping live-session scan after %d entries; assuming live session may exist", checked) + return true + } + name := entry.Name() + if !strings.HasSuffix(name, ".json") { + continue + } + checked++ + + sid := strings.TrimSuffix(name, ".json") + info := readSessionPhaseInfo(sessDir, sid) + if info.Phase == "" || info.Phase == string(session.PhaseEnded) { + continue + } + + // ACTIVE/ACTIVE_COMMITTED sessions that haven't had a real agent + // interaction in a long time are likely orphaned from crashed agents. + // Uses LastInteractionTime from JSON (not file modtime) because + // PostCommit saves all session files on every commit, refreshing + // modtime even for stale sessions. + // IDLE sessions are always considered live (user may just be away). + if session.PhaseFromString(info.Phase).IsActive() { + if info.LastInteractionTime != nil && time.Since(*info.LastInteractionTime) > staleActiveSessionThreshold { + wingmanLog("skipping stale active session %s (phase=%s, last_interaction=%s ago)", sid, info.Phase, time.Since(*info.LastInteractionTime).Round(time.Second)) + continue + } + } + + wingmanLog("found live session %s (phase=%s)", sid, info.Phase) + return true + } + + return false +} + +// findSessionStateDir locates the .git/entire-sessions/ directory by +// reading .git to handle both normal repos and worktrees. +func findSessionStateDir(repoRoot string) string { + gitPath := filepath.Join(repoRoot, ".git") + info, err := os.Stat(gitPath) + if err != nil { + return "" + } + + var gitDir string + if info.IsDir() { + // Normal repo: .git is a directory + gitDir = gitPath + } else { + // Worktree: .git is a file containing "gitdir: " + data, readErr := os.ReadFile(gitPath) //nolint:gosec // path from repoRoot + if readErr != nil { + return "" + } + content := strings.TrimSpace(string(data)) + if !strings.HasPrefix(content, "gitdir: ") { + return "" + } + gitDir = strings.TrimPrefix(content, "gitdir: ") + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(repoRoot, gitDir) + } + // For worktrees, session state is in the common dir + // .git/worktrees/ → ../../ is the common .git dir + commonDir := filepath.Join(gitDir, "..", "..") + gitDir = filepath.Clean(commonDir) + } + + sessDir := filepath.Join(gitDir, "entire-sessions") + if _, statErr := os.Stat(sessDir); statErr != nil { + return "" + } + return sessDir +} + +// sessionPhaseInfo holds the subset of session state needed for liveness checks. +type sessionPhaseInfo struct { + Phase string + LastInteractionTime *time.Time +} + +func readSessionPhaseInfo(sessDir, sessionID string) sessionPhaseInfo { + data, err := os.ReadFile(filepath.Join(sessDir, sessionID+".json")) //nolint:gosec // sessDir is from git internals + if err != nil { + return sessionPhaseInfo{} + } + var partial struct { + Phase string `json:"phase"` + LastInteractionTime *time.Time `json:"last_interaction_time,omitempty"` + } + if json.Unmarshal(data, &partial) != nil { + return sessionPhaseInfo{} + } + return sessionPhaseInfo{ + Phase: partial.Phase, + LastInteractionTime: partial.LastInteractionTime, + } +} + +// runWingmanApply is the entrypoint for the __apply subcommand, spawned by the +// stop hook when a pending REVIEW.md is detected. It re-checks preconditions +// and triggers claude --continue to apply the review. +func runWingmanApply(repoRoot string) error { + wingmanLog("apply process started (pid=%d)", os.Getpid()) + + reviewPath := filepath.Join(repoRoot, wingmanReviewFile) + if !fileExists(reviewPath) { + wingmanLog("no REVIEW.md found at %s, nothing to apply", reviewPath) + return nil + } + wingmanLog("REVIEW.md found at %s", reviewPath) + + // Retry prevention: check if apply was already attempted for this review + state := loadWingmanStateDirect(repoRoot) + switch { + case state == nil: + wingmanLog("no wingman state found, proceeding without session check") + case state.ApplyAttemptedAt != nil: + wingmanLog("apply already attempted at %s, skipping", state.ApplyAttemptedAt.Format(time.RFC3339)) + return nil + default: + wingmanLog("wingman state loaded: session=%s", state.SessionID) + } + + // Re-check session hasn't become active (user may have typed during spawn delay). + // IDLE and ENDED are safe — only ACTIVE/ACTIVE_COMMITTED should block. + if state != nil && state.SessionID != "" { + sessDir := findSessionStateDir(repoRoot) + if sessDir != "" { + phaseInfo := readSessionPhaseInfo(sessDir, state.SessionID) + if phaseInfo.Phase != "" && session.PhaseFromString(phaseInfo.Phase).IsActive() { + wingmanLog("session is active (phase=%s), aborting (next stop hook will retry)", phaseInfo.Phase) + return nil + } + wingmanLog("session phase=%s, safe to proceed", phaseInfo.Phase) + } + } + + // Mark apply as attempted BEFORE triggering + if state != nil { + now := time.Now() + state.ApplyAttemptedAt = &now + saveWingmanStateDirect(repoRoot, state) + } + + wingmanLog("triggering auto-apply via claude --continue") + applyStart := time.Now() + if err := triggerAutoApply(repoRoot); err != nil { + wingmanLog("ERROR auto-apply failed after %s: %v", time.Since(applyStart).Round(time.Millisecond), err) + return fmt.Errorf("failed to trigger auto-apply: %w", err) + } + wingmanLog("auto-apply completed in %s", time.Since(applyStart).Round(time.Millisecond)) + + return nil +} + +// triggerAutoApply spawns claude --continue to apply the review suggestions. +// NOTE: This uses --continue which is Claude-specific and not part of the +// Prompter interface. If the wingman agent is not claude-code, auto-apply +// is skipped. +func triggerAutoApply(repoRoot string) error { + agentName := resolveWingmanAgent() + if agentName != agent.AgentNameClaudeCode { + wingmanLog("auto-apply skipped: --continue is only supported for claude-code (agent=%s)", agentName) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), wingmanApplyTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "claude", + "--continue", + "--print", + "--setting-sources", "", + // Auto-accept edits so the agent can modify files and delete REVIEW.md + // without requiring user consent (this runs in a background process). + "--permission-mode", "acceptEdits", + wingmanApplyInstruction, + ) + cmd.Dir = repoRoot + // Strip GIT_* env vars to prevent hook interference, and set + // ENTIRE_WINGMAN_APPLY=1 so git hooks (post-commit) know not to + // trigger another wingman review (prevents infinite recursion). + env := agent.StripGitEnv(os.Environ()) + env = append(env, "ENTIRE_WINGMAN_APPLY=1") + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + return cmd.Run() //nolint:wrapcheck // caller wraps +} + +// saveWingmanStateDirect writes the wingman state file directly to a known path +// under repoRoot, avoiding os.Chdir (which mutates process-wide state). +func saveWingmanStateDirect(repoRoot string, state *WingmanState) { + statePath := filepath.Join(repoRoot, wingmanStateFile) + if err := os.MkdirAll(filepath.Dir(statePath), 0o750); err != nil { + return + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return + } + //nolint:gosec,errcheck // G306: state file is config, not secrets; best-effort write + _ = os.WriteFile(statePath, data, 0o644) +} diff --git a/cmd/entire/cli/wingman_spawn_other.go b/cmd/entire/cli/wingman_spawn_other.go new file mode 100644 index 000000000..efc804462 --- /dev/null +++ b/cmd/entire/cli/wingman_spawn_other.go @@ -0,0 +1,16 @@ +//go:build !unix + +package cli + +// spawnDetachedWingmanReview is a no-op on non-Unix platforms. +// Windows support for detached processes would require different syscall flags +// (CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS), but wingman is best-effort +// so we simply skip it on unsupported platforms. +func spawnDetachedWingmanReview(_, _ string) { + // No-op: detached subprocess spawning not implemented for this platform +} + +// spawnDetachedWingmanApply is a no-op on non-Unix platforms. +func spawnDetachedWingmanApply(_ string) { + // No-op: detached subprocess spawning not implemented for this platform +} diff --git a/cmd/entire/cli/wingman_spawn_unix.go b/cmd/entire/cli/wingman_spawn_unix.go new file mode 100644 index 000000000..bffab08c6 --- /dev/null +++ b/cmd/entire/cli/wingman_spawn_unix.go @@ -0,0 +1,135 @@ +//go:build unix + +package cli + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "syscall" +) + +// spawnDetachedWingmanReview spawns a detached subprocess to run the wingman review. +// repoRoot is the repository root used to locate the log file. +// payloadPath is the path to the JSON payload file. +// On Unix, this uses process group detachment so the subprocess continues +// after the parent exits. +func spawnDetachedWingmanReview(repoRoot, payloadPath string) { + executable, err := os.Executable() + if err != nil { + return + } + + //nolint:gosec // G204: payloadPath is controlled internally, not user input + cmd := exec.CommandContext(context.Background(), executable, "wingman", "__review", payloadPath) + + // Detach from parent process group so subprocess survives parent exit + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + // Don't hold the working directory + cmd.Dir = "/" + + // Inherit environment (needed for PATH, git config, etc.) + cmd.Env = os.Environ() + + // Redirect stderr to a log file for debugging the background process. + // This catches panics, errors, and all wingmanLog() output. + cmd.Stdout = io.Discard + var logFile *os.File + logDir := filepath.Join(repoRoot, ".entire", "logs") + if mkErr := os.MkdirAll(logDir, 0o750); mkErr == nil { + //nolint:gosec // G304: path is constructed from repoRoot + constants + if f, openErr := os.OpenFile(filepath.Join(logDir, "wingman.log"), + os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600); openErr == nil { + logFile = f + cmd.Stderr = f + } else { + cmd.Stderr = io.Discard + } + } else { + cmd.Stderr = io.Discard + } + + // Start the process (non-blocking) + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "[wingman] Failed to spawn review subprocess: %v\n", err) + if logFile != nil { + _ = logFile.Close() + } + return + } + + pid := cmd.Process.Pid + fmt.Fprintf(os.Stderr, "[wingman] Review subprocess spawned (pid=%d)\n", pid) + + // Close the parent's copy of the log file descriptor. The child process + // received its own copy via dup during Start(), so this won't affect it. + if logFile != nil { + _ = logFile.Close() + } + + // Release the process so it can run independently + //nolint:errcheck // Best effort - process should continue regardless + _ = cmd.Process.Release() +} + +// spawnDetachedWingmanApply spawns a detached subprocess to auto-apply the +// pending REVIEW.md via claude --continue. Called from the stop hook when a +// review is pending after the agent turn ends. +func spawnDetachedWingmanApply(repoRoot string) { + executable, err := os.Executable() + if err != nil { + return + } + + //nolint:gosec // G204: repoRoot is from paths.RepoRoot(), not user input + cmd := exec.CommandContext(context.Background(), executable, "wingman", "__apply", repoRoot) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + cmd.Dir = "/" + cmd.Env = os.Environ() + + cmd.Stdout = io.Discard + var applyLogFile *os.File + logDir := filepath.Join(repoRoot, ".entire", "logs") + if mkErr := os.MkdirAll(logDir, 0o750); mkErr == nil { + //nolint:gosec // G304: path is constructed from repoRoot + constants + if f, openErr := os.OpenFile(filepath.Join(logDir, "wingman.log"), + os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600); openErr == nil { + applyLogFile = f + cmd.Stderr = f + } else { + cmd.Stderr = io.Discard + } + } else { + cmd.Stderr = io.Discard + } + + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "[wingman] Failed to spawn apply subprocess: %v\n", err) + if applyLogFile != nil { + _ = applyLogFile.Close() + } + return + } + + pid := cmd.Process.Pid + fmt.Fprintf(os.Stderr, "[wingman] Apply subprocess spawned (pid=%d)\n", pid) + + // Close the parent's copy of the log file descriptor. The child process + // received its own copy via dup during Start(), so this won't affect it. + if applyLogFile != nil { + _ = applyLogFile.Close() + } + + //nolint:errcheck // Best effort - process should continue regardless + _ = cmd.Process.Release() +} diff --git a/cmd/entire/cli/wingman_test.go b/cmd/entire/cli/wingman_test.go new file mode 100644 index 000000000..765520739 --- /dev/null +++ b/cmd/entire/cli/wingman_test.go @@ -0,0 +1,1194 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestComputeFilesHash_Deterministic(t *testing.T) { + t.Parallel() + + files := []string{"b.go", "a.go", "c.go"} + hash1 := computeFilesHash(files) + hash2 := computeFilesHash(files) + + if hash1 != hash2 { + t.Errorf("expected deterministic hash, got %s and %s", hash1, hash2) + } + + // Order shouldn't matter + files2 := []string{"c.go", "a.go", "b.go"} + hash3 := computeFilesHash(files2) + + if hash1 != hash3 { + t.Errorf("expected order-independent hash, got %s and %s", hash1, hash3) + } +} + +func TestComputeFilesHash_DifferentFiles(t *testing.T) { + t.Parallel() + + hash1 := computeFilesHash([]string{"a.go", "b.go"}) + hash2 := computeFilesHash([]string{"a.go", "c.go"}) + + if hash1 == hash2 { + t.Error("expected different hashes for different file lists") + } +} + +func TestComputeFilesHash_Empty(t *testing.T) { + t.Parallel() + + hash := computeFilesHash(nil) + if hash == "" { + t.Error("expected non-empty hash for empty file list") + } +} + +func TestWingmanPayload_RoundTrip(t *testing.T) { + t.Parallel() + + payload := WingmanPayload{ + SessionID: "test-session-123", + RepoRoot: "/tmp/repo", + BaseCommit: "abc123def456", + ModifiedFiles: []string{"main.go", "util.go"}, + NewFiles: []string{"new.go"}, + DeletedFiles: []string{"old.go"}, + Prompts: []string{"Fix the bug", "Add tests"}, + CommitMessage: "fix: resolve issue", + } + + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded WingmanPayload + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if decoded.SessionID != payload.SessionID { + t.Errorf("session_id: got %q, want %q", decoded.SessionID, payload.SessionID) + } + if decoded.RepoRoot != payload.RepoRoot { + t.Errorf("repo_root: got %q, want %q", decoded.RepoRoot, payload.RepoRoot) + } + if decoded.BaseCommit != payload.BaseCommit { + t.Errorf("base_commit: got %q, want %q", decoded.BaseCommit, payload.BaseCommit) + } + if len(decoded.ModifiedFiles) != len(payload.ModifiedFiles) { + t.Errorf("modified_files: got %d, want %d", len(decoded.ModifiedFiles), len(payload.ModifiedFiles)) + } + if len(decoded.NewFiles) != len(payload.NewFiles) { + t.Errorf("new_files: got %d, want %d", len(decoded.NewFiles), len(payload.NewFiles)) + } + if len(decoded.DeletedFiles) != len(payload.DeletedFiles) { + t.Errorf("deleted_files: got %d, want %d", len(decoded.DeletedFiles), len(payload.DeletedFiles)) + } + if len(decoded.Prompts) != len(payload.Prompts) { + t.Errorf("prompts: got %d, want %d", len(decoded.Prompts), len(payload.Prompts)) + } + if decoded.CommitMessage != payload.CommitMessage { + t.Errorf("commit_message: got %q, want %q", decoded.CommitMessage, payload.CommitMessage) + } +} + +func TestBuildReviewPrompt_IncludesAllSections(t *testing.T) { + t.Parallel() + + prompts := []string{"Fix the authentication bug"} + fileList := "auth.go (modified), auth_test.go (new)" + diff := `diff --git a/auth.go b/auth.go +--- a/auth.go ++++ b/auth.go +@@ -10,6 +10,8 @@ func Login(user, pass string) error { ++ if user == "" { ++ return errors.New("empty user") ++ } +` + + commitMsg := "Fix empty user login crash" + sessionCtx := "## Summary\nFixed authentication bug for empty usernames" + + result := buildReviewPrompt(prompts, commitMsg, sessionCtx, "test-session-456", fileList, diff) + + if !strings.Contains(result, "Fix the authentication bug") { + t.Error("prompt should contain user prompt") + } + if !strings.Contains(result, "auth.go (modified)") { + t.Error("prompt should contain file list") + } + if !strings.Contains(result, "diff --git") { + t.Error("prompt should contain diff") + } + if !strings.Contains(result, "intent-aware review") { + t.Error("prompt should contain reviewer instruction") + } + if !strings.Contains(result, "Fix empty user login crash") { + t.Error("prompt should contain commit message") + } + if !strings.Contains(result, "Fixed authentication bug") { + t.Error("prompt should contain session context") + } + if !strings.Contains(result, ".entire/metadata/test-session-456/full.jsonl") { + t.Error("prompt should contain checkpoint transcript path") + } + if !strings.Contains(result, ".entire/metadata/test-session-456/prompt.txt") { + t.Error("prompt should contain checkpoint prompt path") + } + if !strings.Contains(result, ".entire/metadata/test-session-456/context.md") { + t.Error("prompt should contain checkpoint context path") + } +} + +func TestBuildReviewPrompt_EmptyPrompts(t *testing.T) { + t.Parallel() + + result := buildReviewPrompt(nil, "", "", "", "file.go (modified)", "some diff") + + if !strings.Contains(result, "(no prompts captured)") { + t.Error("should show no-prompts placeholder for empty prompts") + } + if !strings.Contains(result, "(no commit message)") { + t.Error("should show placeholder for empty commit message") + } + if !strings.Contains(result, "(no session context available)") { + t.Error("should show placeholder for empty session context") + } +} + +func TestBuildReviewPrompt_TruncatesLargeDiff(t *testing.T) { + t.Parallel() + + // Create a diff larger than maxDiffSize + largeDiff := strings.Repeat("x", maxDiffSize+1000) + + result := buildReviewPrompt([]string{"test"}, "", "", "test-session", "file.go", largeDiff) + + if !strings.Contains(result, "diff truncated at 100KB") { + t.Error("should truncate large diffs") + } + // The prompt should not contain the full diff + if strings.Contains(result, strings.Repeat("x", maxDiffSize+1000)) { + t.Error("should not contain the full oversized diff") + } +} + +func TestWingmanState_SaveLoad(t *testing.T) { + // Uses t.Chdir so cannot be parallel + tmpDir := t.TempDir() + + // Initialize a real git repo (paths.AbsPath needs git rev-parse) + cmd := exec.CommandContext(context.Background(), "git", "init", tmpDir) + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git init: %v", err) + } + + // Create .entire directory + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatalf("failed to create .entire directory: %v", err) + } + + t.Chdir(tmpDir) + + state := &WingmanState{ + SessionID: "test-session", + FilesHash: "abc123", + ReviewApplied: false, + } + + if err := saveWingmanState(state); err != nil { + t.Fatalf("failed to save state: %v", err) + } + + loaded, err := loadWingmanState() + if err != nil { + t.Fatalf("failed to load state: %v", err) + } + + if loaded.SessionID != state.SessionID { + t.Errorf("session_id: got %q, want %q", loaded.SessionID, state.SessionID) + } + if loaded.FilesHash != state.FilesHash { + t.Errorf("files_hash: got %q, want %q", loaded.FilesHash, state.FilesHash) + } + if loaded.ReviewApplied != state.ReviewApplied { + t.Errorf("review_applied: got %v, want %v", loaded.ReviewApplied, state.ReviewApplied) + } +} + +func TestIsWingmanEnabled_Settings(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + options map[string]any + expected bool + }{ + { + name: "nil options", + options: nil, + expected: false, + }, + { + name: "empty options", + options: map[string]any{}, + expected: false, + }, + { + name: "wingman not present", + options: map[string]any{"summarize": map[string]any{"enabled": true}}, + expected: false, + }, + { + name: "wingman enabled", + options: map[string]any{"wingman": map[string]any{"enabled": true}}, + expected: true, + }, + { + name: "wingman disabled", + options: map[string]any{"wingman": map[string]any{"enabled": false}}, + expected: false, + }, + { + name: "wingman wrong type", + options: map[string]any{"wingman": "invalid"}, + expected: false, + }, + { + name: "wingman enabled wrong type", + options: map[string]any{"wingman": map[string]any{"enabled": "yes"}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + s := &EntireSettings{ + StrategyOptions: tt.options, + } + if got := s.IsWingmanEnabled(); got != tt.expected { + t.Errorf("IsWingmanEnabled() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestWingmanState_ApplyAttemptedAt_RoundTrip(t *testing.T) { + t.Parallel() + + now := time.Now().Truncate(time.Second) + state := &WingmanState{ + SessionID: "sess-1", + FilesHash: "hash1", + ReviewedAt: now, + ReviewApplied: false, + ApplyAttemptedAt: &now, + } + + data, err := json.Marshal(state) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded WingmanState + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if decoded.ApplyAttemptedAt == nil { + t.Fatal("ApplyAttemptedAt should not be nil after round-trip") + } + if !decoded.ApplyAttemptedAt.Truncate(time.Second).Equal(now) { + t.Errorf("ApplyAttemptedAt: got %v, want %v", decoded.ApplyAttemptedAt, now) + } +} + +func TestWingmanState_ApplyAttemptedAt_OmitEmpty(t *testing.T) { + t.Parallel() + + state := &WingmanState{ + SessionID: "sess-1", + FilesHash: "hash1", + } + + data, err := json.Marshal(state) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + if strings.Contains(string(data), "apply_attempted_at") { + t.Error("ApplyAttemptedAt should be omitted when nil") + } +} + +func TestLoadWingmanStateDirect(t *testing.T) { + t.Parallel() + + t.Run("missing file returns nil", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + got := loadWingmanStateDirect(tmpDir) + if got != nil { + t.Errorf("expected nil for missing file, got %+v", got) + } + }) + + t.Run("valid file returns state", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + now := time.Now() + stateJSON := `{"session_id":"sess-1","files_hash":"hash1","reviewed_at":"` + now.Format(time.RFC3339Nano) + `","review_applied":false}` + if err := os.WriteFile(filepath.Join(entireDir, "wingman-state.json"), []byte(stateJSON), 0o644); err != nil { + t.Fatal(err) + } + + got := loadWingmanStateDirect(tmpDir) + if got == nil { + t.Fatal("expected non-nil state") + } + if got.SessionID != "sess-1" { + t.Errorf("SessionID: got %q, want %q", got.SessionID, "sess-1") + } + }) +} + +func TestShouldSkipPendingReview_NoReviewFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + t.Fatal(err) + } + + if shouldSkipPendingReview(tmpDir, "sess-1") { + t.Error("should not skip when no REVIEW.md exists") + } +} + +func TestShouldSkipPendingReview_SameSession(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + // Write REVIEW.md + if err := os.WriteFile(filepath.Join(entireDir, "REVIEW.md"), []byte("review"), 0o644); err != nil { + t.Fatal(err) + } + + // Write state with same session + saveWingmanStateDirect(tmpDir, &WingmanState{ + SessionID: "sess-1", + FilesHash: "hash1", + ReviewedAt: time.Now(), + }) + + if !shouldSkipPendingReview(tmpDir, "sess-1") { + t.Error("should skip when same session has fresh review") + } +} + +func TestShouldSkipPendingReview_DifferentSession(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + reviewPath := filepath.Join(entireDir, "REVIEW.md") + if err := os.WriteFile(reviewPath, []byte("stale review"), 0o644); err != nil { + t.Fatal(err) + } + + saveWingmanStateDirect(tmpDir, &WingmanState{ + SessionID: "old-session", + FilesHash: "hash1", + ReviewedAt: time.Now(), + }) + + if shouldSkipPendingReview(tmpDir, "new-session") { + t.Error("should not skip when review is from different session") + } + + // REVIEW.md should have been cleaned up + if _, err := os.Stat(reviewPath); err == nil { + t.Error("stale REVIEW.md should have been deleted") + } +} + +func TestShouldSkipPendingReview_StaleTTL(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + reviewPath := filepath.Join(entireDir, "REVIEW.md") + if err := os.WriteFile(reviewPath, []byte("old review"), 0o644); err != nil { + t.Fatal(err) + } + + // State with same session but old ReviewedAt + saveWingmanStateDirect(tmpDir, &WingmanState{ + SessionID: "sess-1", + FilesHash: "hash1", + ReviewedAt: time.Now().Add(-2 * time.Hour), // 2 hours old + }) + + if shouldSkipPendingReview(tmpDir, "sess-1") { + t.Error("should not skip when review is older than TTL") + } + + if _, err := os.Stat(reviewPath); err == nil { + t.Error("stale REVIEW.md should have been deleted") + } +} + +func TestHasAnyLiveSession_NoSessionDir(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + // No .git at all + if hasAnyLiveSession(tmpDir) { + t.Error("should return false with no .git directory") + } +} + +func TestHasAnyLiveSession_EmptySessionDir(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + gitDir := filepath.Join(tmpDir, ".git") + if err := os.MkdirAll(filepath.Join(gitDir, "entire-sessions"), 0o755); err != nil { + t.Fatal(err) + } + + if hasAnyLiveSession(tmpDir) { + t.Error("should return false with empty session dir") + } +} + +func TestHasAnyLiveSession_IdleSession(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + + // Create an IDLE session + if err := os.WriteFile(filepath.Join(sessDir, "sess-idle.json"), []byte(`{"phase":"idle"}`), 0o644); err != nil { + t.Fatal(err) + } + + if !hasAnyLiveSession(tmpDir) { + t.Error("should return true when IDLE session exists") + } +} + +func TestHasAnyLiveSession_ActiveSession(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(sessDir, "sess-active.json"), []byte(`{"phase":"active"}`), 0o644); err != nil { + t.Fatal(err) + } + + if !hasAnyLiveSession(tmpDir) { + t.Error("should return true when ACTIVE session exists") + } +} + +func TestHasAnyLiveSession_AllEnded(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(sessDir, "sess-1.json"), []byte(`{"phase":"ended"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessDir, "sess-2.json"), []byte(`{"phase":"ended"}`), 0o644); err != nil { + t.Fatal(err) + } + + if hasAnyLiveSession(tmpDir) { + t.Error("should return false when all sessions are ended") + } +} + +func TestHasAnyLiveSession_MixedPhases(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(sessDir, "sess-ended.json"), []byte(`{"phase":"ended"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessDir, "sess-idle.json"), []byte(`{"phase":"idle"}`), 0o644); err != nil { + t.Fatal(err) + } + + if !hasAnyLiveSession(tmpDir) { + t.Error("should return true when at least one non-ended session exists") + } +} + +func TestLoadSettingsTarget_Local(t *testing.T) { + // Uses t.Chdir so cannot be parallel + tmpDir := t.TempDir() + + cmd := exec.CommandContext(context.Background(), "git", "init", tmpDir) + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git init: %v", err) + } + + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + // Write project settings with wingman disabled + projectSettings := `{"strategy": "manual-commit", "enabled": true, "strategy_options": {"wingman": {"enabled": false}}}` + if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(projectSettings), 0o644); err != nil { + t.Fatal(err) + } + + // Write local settings with different strategy + localSettings := `{"strategy": "` + strategyDisplayAutoCommit + `"}` + if err := os.WriteFile(filepath.Join(entireDir, "settings.local.json"), []byte(localSettings), 0o644); err != nil { + t.Fatal(err) + } + + t.Chdir(tmpDir) + + t.Run("local=false returns merged settings", func(t *testing.T) { + s, err := loadSettingsTarget(false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Merged: local overrides project strategy + if s.Strategy != strategyDisplayAutoCommit { + t.Errorf("Strategy = %q, want %q", s.Strategy, strategyDisplayAutoCommit) + } + }) + + t.Run("local=true returns only local settings", func(t *testing.T) { + s, err := loadSettingsTarget(true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Strategy != strategyDisplayAutoCommit { + t.Errorf("Strategy = %q, want %q", s.Strategy, strategyDisplayAutoCommit) + } + // Local file has no wingman settings + if s.IsWingmanEnabled() { + t.Error("local settings should not have wingman enabled") + } + }) +} + +func TestSaveSettingsTarget_Local(t *testing.T) { + // Uses t.Chdir so cannot be parallel + tmpDir := t.TempDir() + + cmd := exec.CommandContext(context.Background(), "git", "init", tmpDir) + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git init: %v", err) + } + + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + t.Chdir(tmpDir) + + s := &EntireSettings{ + StrategyOptions: map[string]any{ + "wingman": map[string]any{"enabled": true}, + }, + } + + t.Run("local=true saves to local file", func(t *testing.T) { + if err := saveSettingsTarget(s, true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + localPath := filepath.Join(entireDir, "settings.local.json") + data, err := os.ReadFile(localPath) + if err != nil { + t.Fatalf("local settings file should exist: %v", err) + } + if !strings.Contains(string(data), `"wingman"`) { + t.Error("local settings should contain wingman config") + } + + // Project file should not exist + projectPath := filepath.Join(entireDir, "settings.json") + if _, err := os.Stat(projectPath); err == nil { + t.Error("project settings file should not have been created") + } + }) + + t.Run("local=false saves to project file", func(t *testing.T) { + if err := saveSettingsTarget(s, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + projectPath := filepath.Join(entireDir, "settings.json") + data, err := os.ReadFile(projectPath) + if err != nil { + t.Fatalf("project settings file should exist: %v", err) + } + if !strings.Contains(string(data), `"wingman"`) { + t.Error("project settings should contain wingman config") + } + }) +} + +func TestShouldSkipPendingReview_OrphanNoState(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + reviewPath := filepath.Join(entireDir, "REVIEW.md") + if err := os.WriteFile(reviewPath, []byte("orphan review"), 0o644); err != nil { + t.Fatal(err) + } + + // No state file + if shouldSkipPendingReview(tmpDir, "sess-1") { + t.Error("should not skip when no state file exists (orphan)") + } + + if _, err := os.Stat(reviewPath); err == nil { + t.Error("orphan REVIEW.md should have been deleted") + } +} + +func TestHasAnyLiveSession_StaleActiveSessionSkipped(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + + // Create an ACTIVE session with last_interaction_time beyond threshold. + // Uses JSON field (not file modtime) for staleness detection. + staleTime := time.Now().Add(-staleActiveSessionThreshold - 1*time.Hour) + sessData := fmt.Sprintf(`{"phase":"active","last_interaction_time":"%s"}`, staleTime.Format(time.RFC3339Nano)) + sessFile := filepath.Join(sessDir, "stale-active.json") + if err := os.WriteFile(sessFile, []byte(sessData), 0o644); err != nil { + t.Fatal(err) + } + + if hasAnyLiveSession(tmpDir) { + t.Error("should return false when only active session is beyond staleActiveSessionThreshold") + } +} + +func TestHasAnyLiveSession_StaleIdleSessionNotSkipped(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + + // Create an IDLE session with an old last_interaction_time. + // IDLE sessions should always count as live (user may just be away). + oldTime := time.Now().Add(-staleActiveSessionThreshold - 1*time.Hour) + sessData := fmt.Sprintf(`{"phase":"idle","last_interaction_time":"%s"}`, oldTime.Format(time.RFC3339Nano)) + sessFile := filepath.Join(sessDir, "old-idle.json") + if err := os.WriteFile(sessFile, []byte(sessData), 0o644); err != nil { + t.Fatal(err) + } + + if !hasAnyLiveSession(tmpDir) { + t.Error("should return true for IDLE session regardless of age (user may be away)") + } +} + +func TestHasAnyLiveSession_FreshActiveNotSkipped(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + + // Create a stale ACTIVE_COMMITTED session (should be skipped) + staleTime := time.Now().Add(-staleActiveSessionThreshold - 1*time.Hour) + staleData := fmt.Sprintf(`{"phase":"active_committed","last_interaction_time":"%s"}`, staleTime.Format(time.RFC3339Nano)) + staleFile := filepath.Join(sessDir, "stale.json") + if err := os.WriteFile(staleFile, []byte(staleData), 0o644); err != nil { + t.Fatal(err) + } + + // Create a fresh IDLE session (should not be skipped) + freshFile := filepath.Join(sessDir, "fresh.json") + if err := os.WriteFile(freshFile, []byte(`{"phase":"idle"}`), 0o644); err != nil { + t.Fatal(err) + } + + if !hasAnyLiveSession(tmpDir) { + t.Error("should return true when a fresh live session exists alongside stale ones") + } +} + +func TestHasAnyLiveSession_RecentModtimeButStaleInteraction(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + + // Simulate the PostCommit bug: file modtime is recent (just written), + // but LastInteractionTime is old because no TurnStart/TurnEnd occurred. + // This reproduces the scenario where PostCommit saves stale sessions, + // refreshing modtime without updating LastInteractionTime. + staleTime := time.Now().Add(-staleActiveSessionThreshold - 1*time.Hour) + sessData := fmt.Sprintf(`{"phase":"active_committed","last_interaction_time":"%s"}`, staleTime.Format(time.RFC3339Nano)) + sessFile := filepath.Join(sessDir, "stale-but-recent-modtime.json") + if err := os.WriteFile(sessFile, []byte(sessData), 0o644); err != nil { + t.Fatal(err) + } + // File modtime is NOW (just created) — but LastInteractionTime is old. + + if hasAnyLiveSession(tmpDir) { + t.Error("should return false: LastInteractionTime is stale even though file modtime is recent") + } +} + +func TestFindPrompterAgents(t *testing.T) { + t.Parallel() + + agents := findPrompterAgents() + if len(agents) == 0 { + t.Fatal("expected at least one Prompter agent (claude-code)") + } + + // claude-code should always be in the list + found := false + for _, ag := range agents { + if ag.Name() == testAgentName { + found = true + break + } + } + if !found { + t.Error("expected claude-code in Prompter agents") + } +} + +func TestResolveWingmanEnableAgent_ValidFlag(t *testing.T) { + t.Parallel() + + // claude-code is a valid Prompter agent + result, err := resolveWingmanEnableAgent(nil, testAgentName) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != testAgentName { + t.Errorf("got %q, want %q", result, testAgentName) + } +} + +func TestResolveWingmanEnableAgent_UnknownAgent(t *testing.T) { + t.Parallel() + + _, err := resolveWingmanEnableAgent(nil, "nonexistent-agent") + if err == nil { + t.Fatal("expected error for unknown agent") + } + if !strings.Contains(err.Error(), "unknown agent") { + t.Errorf("expected 'unknown agent' error, got: %v", err) + } +} + +func TestResolveWingmanEnableAgent_NonPrompterAgent(t *testing.T) { + t.Parallel() + + // gemini is registered but does not implement Prompter + _, err := resolveWingmanEnableAgent(nil, "gemini") + if err == nil { + t.Fatal("expected error for non-Prompter agent") + } + if !strings.Contains(err.Error(), "does not support wingman reviews") { + t.Errorf("expected 'does not support wingman reviews' error, got: %v", err) + } +} + +func TestResolveWingmanEnableModel_WithFlag(t *testing.T) { + t.Parallel() + + result, err := resolveWingmanEnableModel(nil, "sonnet") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "sonnet" { + t.Errorf("got %q, want %q", result, "sonnet") + } +} + +func TestResolveWingmanEnableModel_NonInteractive(t *testing.T) { + // Non-interactive mode (no TTY) should return empty string (use runtime default) + t.Setenv("ENTIRE_TEST_TTY", "0") + + result, err := resolveWingmanEnableModel(nil, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "" { + t.Errorf("got %q, want empty (runtime default)", result) + } +} + +func TestWingmanEnable_WritesAgentModel(t *testing.T) { + // Uses t.Chdir so cannot be parallel + tmpDir := t.TempDir() + + cmd := exec.CommandContext(context.Background(), "git", "init", tmpDir) + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git init: %v", err) + } + + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + // Write initial settings with entire enabled + initialSettings := testSettingsEnabled + if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(initialSettings), 0o644); err != nil { + t.Fatal(err) + } + + t.Chdir(tmpDir) + + // Simulate wingman enable with agent + model + s, err := loadSettingsTarget(false) + if err != nil { + t.Fatalf("failed to load settings: %v", err) + } + + if s.StrategyOptions == nil { + s.StrategyOptions = make(map[string]any) + } + wingmanOpts := map[string]any{ + "enabled": true, + "agent": testAgentName, + "model": "sonnet", + } + s.StrategyOptions["wingman"] = wingmanOpts + + if err := saveSettingsTarget(s, false); err != nil { + t.Fatalf("failed to save settings: %v", err) + } + + // Reload and verify + loaded, err := loadSettingsTarget(false) + if err != nil { + t.Fatalf("failed to reload settings: %v", err) + } + + if !loaded.IsWingmanEnabled() { + t.Error("wingman should be enabled") + } + if a := loaded.WingmanAgent(); a != testAgentName { + t.Errorf("WingmanAgent() = %q, want %q", a, testAgentName) + } + if m := loaded.WingmanModel(); m != "sonnet" { + t.Errorf("WingmanModel() = %q, want %q", m, "sonnet") + } +} + +func TestWingmanEnable_NoAgentModel_DefaultsAtRuntime(t *testing.T) { + // Uses t.Chdir so cannot be parallel + tmpDir := t.TempDir() + + cmd := exec.CommandContext(context.Background(), "git", "init", tmpDir) + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git init: %v", err) + } + + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + initialSettings := testSettingsEnabled + if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(initialSettings), 0o644); err != nil { + t.Fatal(err) + } + + t.Chdir(tmpDir) + + // Simulate wingman enable without agent/model (non-interactive) + s, err := loadSettingsTarget(false) + if err != nil { + t.Fatalf("failed to load settings: %v", err) + } + + if s.StrategyOptions == nil { + s.StrategyOptions = make(map[string]any) + } + s.StrategyOptions["wingman"] = map[string]any{"enabled": true} + + if err := saveSettingsTarget(s, false); err != nil { + t.Fatalf("failed to save settings: %v", err) + } + + // Reload and verify — agent/model should be empty (runtime defaults apply) + loaded, err := loadSettingsTarget(false) + if err != nil { + t.Fatalf("failed to reload settings: %v", err) + } + + if !loaded.IsWingmanEnabled() { + t.Error("wingman should be enabled") + } + if a := loaded.WingmanAgent(); a != "" { + t.Errorf("WingmanAgent() = %q, want empty (runtime default)", a) + } + if m := loaded.WingmanModel(); m != "" { + t.Errorf("WingmanModel() = %q, want empty (runtime default)", m) + } +} + +func TestRunWingmanApply_EndedPhaseProceeds(t *testing.T) { + t.Parallel() + + // This test verifies that runWingmanApply does NOT abort when the session + // phase is ENDED (the bug fix). We can't test the full auto-apply (it + // spawns claude CLI), but we can verify it passes the phase check. + tmpDir := t.TempDir() + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + // Create REVIEW.md + reviewPath := filepath.Join(entireDir, "REVIEW.md") + if err := os.WriteFile(reviewPath, []byte("review content"), 0o644); err != nil { + t.Fatal(err) + } + + // Create wingman state + saveWingmanStateDirect(tmpDir, &WingmanState{ + SessionID: "sess-ended", + FilesHash: "hash1", + ReviewedAt: time.Now(), + }) + + // Create session state dir with ENDED phase + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessDir, "sess-ended.json"), []byte(`{"phase":"ended"}`), 0o644); err != nil { + t.Fatal(err) + } + + // runWingmanApply will pass the phase check but fail at triggerAutoApply + // (no claude CLI). The important thing is it doesn't return nil early + // with "session became active" — it should get past the phase check. + err := runWingmanApply(tmpDir) + if err == nil { + t.Log("runWingmanApply returned nil (auto-apply succeeded or no claude CLI)") + } else { + // Expected: fails at triggerAutoApply because claude CLI isn't available + if strings.Contains(err.Error(), "session became active") { + t.Error("should not abort with 'session became active' for ENDED phase") + } + t.Logf("runWingmanApply failed as expected (no claude CLI): %v", err) + } + + // Verify apply was attempted (state should be updated) + state := loadWingmanStateDirect(tmpDir) + if state == nil { + t.Fatal("expected wingman state to exist") + } + if state.ApplyAttemptedAt == nil { + t.Error("ApplyAttemptedAt should be set after passing phase check") + } +} + +func TestRunWingmanApply_ActivePhaseAborts(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + // Create REVIEW.md + reviewPath := filepath.Join(entireDir, "REVIEW.md") + if err := os.WriteFile(reviewPath, []byte("review content"), 0o644); err != nil { + t.Fatal(err) + } + + // Create wingman state + saveWingmanStateDirect(tmpDir, &WingmanState{ + SessionID: "sess-active", + FilesHash: "hash1", + ReviewedAt: time.Now(), + }) + + // Create session state dir with ACTIVE phase + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessDir, "sess-active.json"), []byte(`{"phase":"active"}`), 0o644); err != nil { + t.Fatal(err) + } + + // runWingmanApply should return nil (aborted) without attempting apply + err := runWingmanApply(tmpDir) + if err != nil { + t.Errorf("expected nil error (clean abort), got: %v", err) + } + + // Verify apply was NOT attempted + state := loadWingmanStateDirect(tmpDir) + if state != nil && state.ApplyAttemptedAt != nil { + t.Error("ApplyAttemptedAt should not be set when phase is ACTIVE") + } +} + +func TestRunWingmanApply_ActiveCommittedPhaseAborts(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + // Create REVIEW.md + if err := os.WriteFile(filepath.Join(entireDir, "REVIEW.md"), []byte("review"), 0o644); err != nil { + t.Fatal(err) + } + + // Create wingman state + saveWingmanStateDirect(tmpDir, &WingmanState{ + SessionID: "sess-ac", + FilesHash: "hash1", + ReviewedAt: time.Now(), + }) + + // Create session state dir with ACTIVE_COMMITTED phase + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessDir, "sess-ac.json"), []byte(`{"phase":"active_committed"}`), 0o644); err != nil { + t.Fatal(err) + } + + err := runWingmanApply(tmpDir) + if err != nil { + t.Errorf("expected nil error (clean abort), got: %v", err) + } + + // Verify apply was NOT attempted + state := loadWingmanStateDirect(tmpDir) + if state != nil && state.ApplyAttemptedAt != nil { + t.Error("ApplyAttemptedAt should not be set when phase is ACTIVE_COMMITTED") + } +} + +func TestRunWingmanApply_IdlePhaseProceeds(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + // Create REVIEW.md + if err := os.WriteFile(filepath.Join(entireDir, "REVIEW.md"), []byte("review"), 0o644); err != nil { + t.Fatal(err) + } + + // Create wingman state + saveWingmanStateDirect(tmpDir, &WingmanState{ + SessionID: "sess-idle", + FilesHash: "hash1", + ReviewedAt: time.Now(), + }) + + // Create session state dir with IDLE phase + sessDir := filepath.Join(tmpDir, ".git", "entire-sessions") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessDir, "sess-idle.json"), []byte(`{"phase":"idle"}`), 0o644); err != nil { + t.Fatal(err) + } + + // Should pass phase check (IDLE is safe) then fail at triggerAutoApply + err := runWingmanApply(tmpDir) + + // Verify apply was attempted (passes the phase check) + state := loadWingmanStateDirect(tmpDir) + if state == nil { + t.Fatal("expected wingman state to exist") + } + if state.ApplyAttemptedAt == nil { + t.Error("ApplyAttemptedAt should be set after passing phase check") + } + // We expect an error from triggerAutoApply (no claude CLI), that's fine + _ = err +} diff --git a/docs/architecture/wingman.md b/docs/architecture/wingman.md new file mode 100644 index 000000000..beb9db132 --- /dev/null +++ b/docs/architecture/wingman.md @@ -0,0 +1,326 @@ +# Wingman: Automated Code Review + +Wingman is an automated code review system that reviews agent-produced code changes after each commit and delivers actionable suggestions back to the agent. + +## Overview + +When enabled, wingman runs a background review after each commit (or checkpoint), writes suggestions to `.entire/REVIEW.md`, and ensures the agent addresses them. The system prioritizes **visible delivery** — the user should see the agent reading and applying the review in their terminal. + +## Architecture + +### Components + +| Component | File | Purpose | +|-----------|------|---------| +| Trigger & state | `wingman.go` | Payload, state management, dedup, lock files, stale review cleanup | +| Review process | `wingman_review.go` | Detached subprocess: diff, Claude review call, REVIEW.md creation, session detection | +| Review prompt | `wingman_prompt.go` | Builds the review prompt from diff, prompts, context | +| Instruction | `wingman_instruction.md` | Embedded instruction injected into agent context | +| Process spawning | `wingman_spawn_unix.go` | Detached subprocess spawning (Unix) | +| Process spawning | `wingman_spawn_other.go` | No-op stubs (non-Unix) | +| Hook integration | `hooks_claudecode_handlers.go` | Prompt-submit injection, stop hook notifications, session-end trigger | + +### State Files + +| File | Purpose | +|------|---------| +| `.entire/REVIEW.md` | The review itself, read by the agent | +| `.entire/wingman-state.json` | Dedup state, session ID, apply tracking | +| `.entire/wingman.lock` | Prevents concurrent review spawns | +| `.entire/wingman-payload.json` | Payload passed to detached review process | +| `.entire/logs/wingman.log` | Review process logs (stderr redirect) | + +## Lifecycle + +### Phase 1: Trigger + +A wingman review is triggered after code changes are committed: + +- **Manual-commit strategy**: Git `post-commit` hook calls `triggerWingmanFromCommit()` +- **Auto-commit strategy**: Stop hook calls `triggerWingmanReview()` after `SaveChanges()` + +Before spawning, preconditions are checked: +1. `ENTIRE_WINGMAN_APPLY` env var not set (prevents recursion from auto-apply) +2. No pending REVIEW.md for the current session (`shouldSkipPendingReview()`) +3. Lock file acquired atomically (`acquireWingmanLock()`) +4. File hash dedup — skip if same files were already reviewed this session + +### Phase 2: Review (Detached Process) + +The review runs in a fully detached subprocess (`entire wingman __review `): + +``` +┌─ Detached Process ─────────────────────────────────────┐ +│ 1. Read payload from file │ +│ 2. Wait 10s for agent turn to settle │ +│ 3. Compute diff (merge-base with main/master) │ +│ 4. Load session context from checkpoint metadata │ +│ 5. Build review prompt (diff + prompts + context) │ +│ 6. Call claude --print (sonnet model, read-only tools) │ +│ 7. Write REVIEW.md │ +│ 8. Save dedup state │ +│ 9. Determine delivery path (see Phase 3) │ +│ 10. Remove lock file │ +└────────────────────────────────────────────────────────┘ +``` + +The review process uses `--setting-sources ""` to disable hooks (prevents recursion). The Claude CLI calls (`callClaudeForReview` and `triggerAutoApply`) strip `GIT_*` and `CLAUDECODE` environment variables via `wingmanStripGitEnv()` — `GIT_*` removal prevents index corruption, and `CLAUDECODE` removal prevents the Claude CLI from refusing to start due to nested-session detection. The detached process itself inherits the parent's full environment; stripping happens only when spawning the Claude CLI subprocess. + +### Phase 3: Delivery + +There are two delivery mechanisms. The system chooses based on whether any session is still alive. + +#### Primary: Prompt-Submit Injection (Visible) + +When a live session exists (IDLE, ACTIVE, or ACTIVE_COMMITTED phase — excluding stale ACTIVE sessions older than 4 hours), the review is delivered through the agent's next prompt: + +``` +User sends prompt → UserPromptSubmit hook fires + → REVIEW.md exists on disk + → Inject as additionalContext (mandatory agent instruction) + → Agent reads REVIEW.md, applies suggestions, deletes file + → Agent then proceeds with user's actual request +``` + +The `additionalContext` hook response field adds the instruction directly to Claude's context, making it a mandatory pre-step. The embedded instruction (`wingman_instruction.md`) tells the agent to read the review, address suggestions, delete the file, and briefly tell the user what changed. + +#### Fallback: Background Auto-Apply (Invisible) + +When no live sessions exist (all ENDED or none), REVIEW.md is applied via a background process: + +``` +entire wingman __apply + → Verify REVIEW.md exists + → Check ApplyAttemptedAt not set (retry prevention) + → Re-check session phase is not ACTIVE/ACTIVE_COMMITTED (guard against race) + → claude --continue --print --permission-mode acceptEdits +``` + +This path is **invisible** — it runs silently. It exists as a fallback for when no session will receive the injection (e.g., user closed all sessions during the review window). Both IDLE and ENDED phases are considered safe for auto-apply — only truly active sessions (ACTIVE/ACTIVE_COMMITTED) block it. + +### Trigger Points + +| Trigger | When | What Happens | +|---------|------|-------------| +| **Review process** (`runWingmanReview`) | Review finishes | If no live sessions → background auto-apply. Otherwise defer. | +| **Prompt-submit hook** (`captureInitialState`) | User sends prompt | If REVIEW.md exists → inject as `additionalContext`. | +| **Stop hook** (`triggerWingmanAutoApplyIfPending`) | Agent turn ends | If REVIEW.md exists + no live sessions → spawn `__apply`. | + +## User-Visible Messages + +Wingman outputs `systemMessage` notifications at key points so the user can see what wingman is doing in their agent terminal. These are informational only — they are NOT injected into the agent's context. + +| Message | Hook | Condition | Purpose | +|---------|------|-----------|---------| +| `[Wingman] A code review is pending and will be addressed before your request.` | Prompt-submit | REVIEW.md exists (with `additionalContext` injection) | Tells user the agent will apply a review first | +| `[Wingman] Review in progress...` | Prompt-submit | Lock file exists (no REVIEW.md) | Tells user a review is running in the background | +| `[Wingman] Reviewing your changes...` | Stop | Lock file exists | Tells user a review was triggered and is still running | +| `[Wingman] Review pending — will be addressed on your next prompt` | Stop | REVIEW.md exists (no lock file) | Tells user a completed review will be delivered next prompt | + +The prompt-submit REVIEW.md injection message is paired with `additionalContext` — the agent sees and acts on the review. All other messages use `outputHookMessage()` which emits `systemMessage`-only JSON (visible in terminal, invisible to agent). + +## Timing + +Typical timeline for a review cycle: + +``` +T+0s Commit happens → wingman review triggered +T+0s Lock file created, payload written +T+10s Initial settle delay completes +T+10s Diff computed (~30-50ms) +T+11s Claude review API call starts +T+30-50s Review received, REVIEW.md written +T+30-50s Delivery path determined +``` + +The 10-second initial delay lets the agent turn fully settle before computing the diff, ensuring all file writes are flushed. + +## Review Prompt Construction + +The review prompt leverages Entire's checkpoint data to give the reviewer **full context about what the developer was trying to accomplish**. This enables intent-aware review — catching not just bugs, but misalignment between what was asked and what was built. A reviewer that only sees the diff cannot evaluate whether the code matches the original request. + +### Context Sources + +| Source | Origin | What It Provides | +|--------|--------|-----------------| +| **Developer prompts** | `prompt.txt` from checkpoint metadata | The original instructions given to the agent — the ground truth of intent | +| **Commit message** | Git commit or auto-commit message | A summary of what was done (may differ from what was asked) | +| **Session context** | `context.md` from checkpoint metadata | Generated summary of key actions, decisions, and session flow | +| **Checkpoint files** | `.entire/metadata//` | Paths provided so the reviewer can read the full transcript, prompts, or context if needed | +| **File list** | Payload from trigger | Which files changed and how (modified/new/deleted) | +| **Branch diff** | `git diff` against merge-base | The actual code changes — computed against `main`/`master` for a holistic branch-level view | + +### Prompt Structure + +``` +┌─ System Role ──────────────────────────────────────────┐ +│ "You are a senior code reviewer performing an │ +│ intent-aware review." │ +├─ Session Context ──────────────────────────────────────┤ +│ Developer's Prompts ... │ +│ Commit Message (plain text) │ +│ Session Context ... │ +│ Checkpoint File Paths (for deeper investigation) │ +├─ Code Changes ─────────────────────────────────────────┤ +│ Files changed: file.go (modified), ... │ +│ Diff ... │ +├─ Review Instructions ──────────────────────────────────┤ +│ Intent alignment (most important) │ +│ Correctness bugs, logic errors, races │ +│ Security injection, secrets, path traversal │ +│ Robustness edge cases, leaks, timeouts │ +│ Do NOT flag style, docs on clear code │ +│ Output format Markdown with severity levels │ +└────────────────────────────────────────────────────────┘ +``` + +### Diff Strategy + +The diff is computed against the **merge-base** with `main`/`master`, not just the latest commit. This gives the reviewer a holistic view of all branch changes rather than a narrow single-commit diff. Fallback chain: + +1. `git merge-base main HEAD` → diff against merge-base (branch-level view) +2. `git merge-base master HEAD` → try master if main doesn't exist +3. `git diff HEAD` → uncommitted changes only +4. `git diff HEAD~1 HEAD` → latest commit if no uncommitted changes + +### Read-Only Tool Access + +The reviewer Claude instance has access to `Read`, `Glob`, and `Grep` tools with `--permission-mode bypassPermissions`. This allows it to read source files beyond the diff, search for related patterns, and inspect checkpoint metadata. Tools are restricted to read-only operations. + +### Output Format + +The reviewer outputs structured Markdown with: +- **Summary**: Does the change accomplish its goal? Overall quality assessment. +- **Issues**: Each with severity (`CRITICAL`, `WARNING`, `SUGGESTION`), file path with line reference, description, and suggested fix. + +Diffs larger than 100KB are truncated to maintain review quality. The output is written directly to `.entire/REVIEW.md`. + +## Stale Review Cleanup + +Reviews can become stale in several scenarios. The `shouldSkipPendingReview()` function handles cleanup: + +| Scenario | Detection | Action | +|----------|-----------|--------| +| REVIEW.md without state file | `state == nil` | Delete REVIEW.md (orphan) | +| REVIEW.md from different session | `state.SessionID != currentSessionID` | Delete REVIEW.md (stale) | +| REVIEW.md older than 1 hour | `time.Since(state.ReviewedAt) > 1h` | Delete REVIEW.md (TTL expired) | +| REVIEW.md from current session | Session matches + fresh | Keep (skip new review) | + +## Retry Prevention + +The `ApplyAttemptedAt` field in `WingmanState` prevents infinite auto-apply attempts: + +- Set to current time before triggering auto-apply +- Reset to `nil` when a new review is written +- Checked before every auto-apply attempt — if set, skip + +## Concurrency Safety + +- **Lock file**: Atomic `O_CREATE|O_EXCL` prevents concurrent review spawns. Stale locks (>30 min) are auto-cleaned. +- **Dedup hash**: SHA256 of sorted file paths prevents re-reviewing identical change sets. +- **Detached processes**: Review and apply run in their own process groups (`Setpgid: true`), surviving parent exit. +- **Environment stripping**: Claude CLI calls strip `GIT_*` env vars (prevents index corruption) and `CLAUDECODE` (prevents nested-session detection refusal) via `wingmanStripGitEnv()`. Applied in `callClaudeForReview()` and `triggerAutoApply()`, not at process spawn time. The summarize package (`summarize/claude.go`) uses an identical `stripGitEnv()` for the same purpose. +- **ENTIRE_WINGMAN_APPLY=1**: Set during auto-apply to prevent the post-commit hook from triggering another review (recursion prevention). +- **Stale session detection**: ACTIVE/ACTIVE_COMMITTED sessions not updated in 4+ hours are considered orphaned (crashed agent) and ignored by `hasAnyLiveSession`. IDLE sessions are always considered live regardless of age. + +## Configuration + +### Commands + +```bash +entire wingman enable [--local] # Enable wingman auto-review +entire wingman disable [--local] # Disable and clean up pending reviews +entire wingman status # Show current status +``` + +**Precondition:** `entire wingman enable` requires Entire to be enabled first (`entire enable`). + +### `--local` Flag + +The `--local` flag controls which settings file is written: + +| Flag | File | Committed to git | Purpose | +|------|------|------------------|---------| +| (default) | `.entire/settings.json` | Yes | Project-wide setting shared with team | +| `--local` | `.entire/settings.local.json` | No (gitignored) | User-specific override | + +When loading settings, both files are merged — local overrides project. The `--local` flag only affects which file is *written* to. + +### Settings Structure + +```json +{ + "strategy_options": { + "wingman": { + "enabled": true + } + } +} +``` + +### Status Output + +`entire wingman status` shows: + +``` +Wingman: enabled +Last review: 2026-02-13T08:45:11+01:00 +Status: pending +Pending review: .entire/REVIEW.md +``` + +Fields shown: enabled/disabled status, last review timestamp (if available), applied/pending status, and pending review file path (if exists). + +### Hidden Subcommands + +| Command | Purpose | +|---------|---------| +| `entire wingman __review ` | Internal: spawned as detached review subprocess | +| `entire wingman __apply ` | Internal: spawned to auto-apply pending REVIEW.md | + +## Key Constants + +| Constant | Value | Purpose | +|----------|-------|---------| +| `wingmanInitialDelay` | 10s | Settle time before computing diff | +| `wingmanReviewModel` | `sonnet` | Model used for reviews | +| `wingmanGitTimeout` | 60s | Timeout for git diff operations | +| `wingmanReviewTimeout` | 5m | Timeout for Claude review API call | +| `wingmanApplyTimeout` | 15m | Timeout for auto-apply process | +| `wingmanStaleReviewTTL` | 1h | Max age before review is cleaned up | +| `staleLockThreshold` | 30m | Max age before lock is considered stale (for lock acquisition) | +| `wingmanNotificationLockThreshold` | 10m | Max lock age for showing "Review in progress" notifications | +| `staleActiveSessionThreshold` | 4h | Max age before ACTIVE session is considered stale/orphaned | +| `maxDiffSize` | 100KB | Maximum diff size included in review prompt (truncates beyond) | + +None of these constants are user-configurable — they are internal to the implementation. + +## Claude CLI Invocations + +### Review Call (`callClaudeForReview`) + +```bash +claude --print \ + --output-format json \ + --model sonnet \ + --setting-sources "" \ + --allowedTools "Read,Glob,Grep" \ + --permission-mode bypassPermissions +``` + +- Working directory: repo root (reviewer can access source files) +- Environment: `wingmanStripGitEnv()` strips `GIT_*` and `CLAUDECODE` +- Input: review prompt via stdin + +### Auto-Apply Call (`triggerAutoApply`) + +```bash +claude --continue \ + --print \ + --setting-sources "" \ + --permission-mode acceptEdits \ + +``` + +- Working directory: repo root +- Environment: `wingmanStripGitEnv()` strips `GIT_*` and `CLAUDECODE`, adds `ENTIRE_WINGMAN_APPLY=1`