Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d13528a
Add wingman automated code review feature
dipree Feb 11, 2026
a6c2d1b
Add debug logging to wingman background review process
dipree Feb 11, 2026
41456e6
Enhance wingman: intent-aware review, post-commit trigger, recursion …
dipree Feb 11, 2026
73d03f2
Inject wingman review instruction into agent context on prompt-submit
dipree Feb 11, 2026
d4dbd80
Extract wingman apply instruction into embedded markdown file
dipree Feb 11, 2026
962a3c9
Rename wingman_apply.md to wingman_instruction.md
dipree Feb 11, 2026
b5c8d26
Use branch-level diff for wingman reviews instead of single commit
dipree Feb 11, 2026
6289e2b
Show wingman status in session start message
dipree Feb 11, 2026
467e432
Merge branch 'main' into dipree/entire-wingman
dipree Feb 12, 2026
3dceaba
Add reliable wingman auto-apply via stop hook and stale review cleanup
dipree Feb 12, 2026
7dcd943
Fix wingman auto-apply not triggering on no-changes turn end
dipree Feb 12, 2026
9ff20c8
Add structured logging for wingman review lifecycle
dipree Feb 12, 2026
5913a8e
Fix isSessionIdle failing in detached wingman subprocesses
dipree Feb 12, 2026
18accf7
Use additionalContext for wingman review injection to ensure it's add…
dipree Feb 12, 2026
6627812
Prefer visible review delivery and add session-end auto-apply trigger
dipree Feb 12, 2026
7491e85
Add timeout to resolveHEAD and fix log file cleanup in spawn helpers
dipree Feb 12, 2026
ef43775
Document review prompt construction and context sources
dipree Feb 12, 2026
2bd71c8
Add --local flag to wingman enable/disable commands
dipree Feb 12, 2026
5fddc49
Fix hook response format to nest additionalContext under hookSpecific…
dipree Feb 12, 2026
b01e554
Restore user-visible SessionStart message and fix hook control flow
dipree Feb 12, 2026
5f69d94
Fix wingman auto-apply never triggering on session close
dipree Feb 12, 2026
ff06729
Add wingman status notifications visible in agent terminal
dipree Feb 12, 2026
118936d
Address PR review comments and update wingman documentation
dipree Feb 12, 2026
121565d
Restore prompt structure diagram in wingman docs
dipree Feb 12, 2026
0825633
Skip wingman lock file notifications when lock is stale
dipree Feb 12, 2026
d2ef783
Allow strategy.Strategy in ireturn lint config
dipree Feb 12, 2026
6808d51
Use tighter lock threshold for wingman status notifications
dipree Feb 12, 2026
69a35cf
Clean up ireturn nolint, add spawn comments, update stale session tests
dipree Feb 12, 2026
e794c91
Show pending review status in SessionStart wingman message
dipree Feb 12, 2026
0ce3f3f
Fix stale active sessions blocking wingman auto-apply
dipree Feb 12, 2026
2315a1e
Merge remote-tracking branch 'origin/main' into dipree/entire-wingman
dipree Feb 12, 2026
46815ae
Fix context param shadowing and log file FD leak in parent process
dipree Feb 12, 2026
2a1f8b4
Merge remote-tracking branch 'origin/main' into dipree/entire-wingman
dipree Feb 13, 2026
8161293
fix: strip CLAUDECODE env var in wingman/summarize subprocess calls
dipree Feb 13, 2026
6706f44
fix: auto-gitignore wingman runtime files
dipree Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .entire/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ settings.local.json
metadata/
current_session
logs/
wingman.lock
wingman-state.json
wingman-payload.json
REVIEW.md
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ linters:
- stdlib
- grpc.DialOption
- github.com/entireio/cli/cmd/entire/cli/agent.Agent
- github.com/entireio/cli/cmd/entire/cli/strategy.Strategy
- 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
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func IsEnabled() (bool, error) {
// GetStrategy returns the configured strategy instance.
// Falls back to default if the configured strategy is not found.
//
//nolint:ireturn // Factory pattern requires returning the interface

func GetStrategy() strategy.Strategy {
s, err := settings.Load()
if err != nil {
Expand Down
72 changes: 66 additions & 6 deletions cmd/entire/cli/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import (
"io"
"log/slog"
"os"
"path/filepath"

"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/session"
"github.com/entireio/cli/cmd/entire/cli/settings"
"github.com/entireio/cli/cmd/entire/cli/strategy"
)

Expand Down Expand Up @@ -265,6 +268,19 @@ func handleSessionStartCommon() error {
// Build informational message
message := "\n\nPowered by Entire:\n This conversation will be linked to your next commit."

// Append wingman note if enabled, with pending review indication
if settings.IsWingmanEnabled() {
if repoRoot, rootErr := paths.RepoRoot(); rootErr == nil {
if _, statErr := os.Stat(filepath.Join(repoRoot, wingmanReviewFile)); statErr == nil {
message += "\n Wingman: a review is pending and will be addressed on your next prompt."
} else {
message += "\n Wingman is active: your changes will be automatically reviewed."
}
} else {
message += "\n Wingman is active: your changes will be automatically reviewed."
}
}

// Check for concurrent sessions and append count if any
strat := GetStrategy()
if concurrentChecker, ok := strat.(strategy.ConcurrentSessionChecker); ok {
Expand Down Expand Up @@ -293,16 +309,60 @@ func handleSessionStartCommon() error {
return nil
}

// 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 Claude Code 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 to stdout
func outputHookResponse(reason string) error {
// 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: reason,
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
}

// 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: 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)
Expand Down
143 changes: 143 additions & 0 deletions cmd/entire/cli/hooks_claudecode_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/cmd/entire/cli/settings"
"github.com/entireio/cli/cmd/entire/cli/strategy"
)

Expand Down Expand Up @@ -75,6 +76,47 @@ func captureInitialState() error {
return err
}

// If a wingman review is pending, inject it as additionalContext so the
// agent addresses it BEFORE the user's request. This is the primary
// delivery mechanism — the agent sees the instruction as mandatory context.
if settings.IsWingmanEnabled() {
repoRoot, rootErr := paths.RepoRoot()
if rootErr == nil {
wingmanLogCtx := logging.WithComponent(context.Background(), "wingman")
if _, statErr := os.Stat(filepath.Join(repoRoot, wingmanReviewFile)); statErr == nil {
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", hookData.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 {
// Hook response written to stdout — must return immediately
// to avoid corrupting the JSON with additional output.
return nil
}
}

// Notify if a review is currently in progress (fresh lock file).
// outputHookMessage writes JSON to stdout; session initialization
// below only touches disk/stderr, so there's no double-write risk.
// Uses wingmanNotificationLockThreshold (10min) — tighter than the
// 30min staleLockThreshold used for lock acquisition.
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", hookData.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)
}
}
}
}

// If strategy implements SessionInitializer, call it to initialize session state
strat := GetStrategy()

Expand Down Expand Up @@ -264,10 +306,13 @@ func commitWithMetadata() error { //nolint:maintidx // already present in codeba
fmt.Fprintf(os.Stderr, "Skipping commit\n")
// Still transition phase even when skipping commit — the turn is ending.
transitionSessionTurnEnd(sessionID)
// Auto-apply pending wingman review even when no file changes this turn
triggerWingmanAutoApplyIfPending(repoRoot)
// Clean up state even when skipping
if err := CleanupPrePromptState(sessionID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", err)
}
outputWingmanStopNotification(repoRoot)
return nil
}

Expand Down Expand Up @@ -387,11 +432,31 @@ func commitWithMetadata() error { //nolint:maintidx // already present in codeba
// For ACTIVE_COMMITTED → IDLE, HandleTurnEnd dispatches ActionCondense.
transitionSessionTurnEnd(sessionID)

// Trigger wingman review for auto-commit strategy (commit already happened
// in SaveChanges). 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)

// Clean up pre-prompt state (CLI responsibility)
if err := CleanupPrePromptState(sessionID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", err)
}

outputWingmanStopNotification(repoRoot)

return nil
}

Expand Down Expand Up @@ -729,9 +794,42 @@ func handleClaudeCodeSessionEnd() error {
if err := markSessionEnded(input.SessionID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to mark session ended: %v\n", err)
}

return nil
}

// 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

// 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 {
_ = outputHookMessage("[Wingman] Review pending \u2014 will be addressed on your next prompt") //nolint:errcheck // best-effort notification
return
}
}

// transitionSessionTurnEnd fires EventTurnEnd to move the session from
// ACTIVE → IDLE (or ACTIVE_COMMITTED → IDLE). Best-effort: logs warnings
// on failure rather than returning errors.
Expand Down Expand Up @@ -761,6 +859,51 @@ func transitionSessionTurnEnd(sessionID string) {
}
}

// 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
}
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)
}


// markSessionEnded transitions the session to ENDED phase via the state machine.
func markSessionEnded(sessionID string) error {
state, err := strategy.LoadSessionState(sessionID)
Expand Down
4 changes: 4 additions & 0 deletions cmd/entire/cli/hooks_git_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
Expand Down
Loading