-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhook_registry.go
More file actions
180 lines (154 loc) · 6.32 KB
/
Copy pathhook_registry.go
File metadata and controls
180 lines (154 loc) · 6.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
// hook_registry.go provides hook command registration for agents.
// The lifecycle dispatcher (DispatchLifecycleEvent) handles all lifecycle events.
// PostTodo is the only hook that's handled directly (not via lifecycle dispatcher).
package cli
import (
"errors"
"fmt"
"log/slog"
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/claudecode"
"github.com/GrayCodeAI/trace/cli/agent/geminicli"
"github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/strategy"
"github.com/GrayCodeAI/trace/perf"
"github.com/spf13/cobra"
)
// agentHookLogCleanup stores the cleanup function for agent hook logging.
// Set by PersistentPreRunE, called by PersistentPostRunE.
var agentHookLogCleanup func()
// currentHookAgentName stores the agent name for the currently executing hook.
// Set by newAgentHookVerbCmdWithLogging before calling the handler.
// This allows handlers to know which agent invoked the hook without guessing.
var currentHookAgentName types.AgentName
// GetCurrentHookAgent returns the agent for the currently executing hook.
// Returns the agent based on the hook command structure (e.g., "trace hooks claude-code ...")
// rather than guessing from directory presence.
// Falls back to GetAgent() if not in a hook context.
func GetCurrentHookAgent() (agent.Agent, error) {
if currentHookAgentName == "" {
return nil, errors.New("not in a hook context: agent name not set")
}
ag, err := agent.Get(currentHookAgentName)
if err != nil {
return nil, fmt.Errorf("getting hook agent %q: %w", currentHookAgentName, err)
}
return ag, nil
}
// newAgentHooksCmd creates a hooks subcommand for an agent that implements HookSupport.
// It dynamically creates subcommands for each hook the agent supports.
func newAgentHooksCmd(agentName types.AgentName, handler agent.HookSupport) *cobra.Command {
cmd := &cobra.Command{
Use: string(agentName),
Short: handler.Description() + " hook handlers",
Hidden: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
agentHookLogCleanup = initHookLogging(cmd.Context())
return nil
},
PersistentPostRunE: func(_ *cobra.Command, _ []string) error {
if agentHookLogCleanup != nil {
agentHookLogCleanup()
}
return nil
},
}
for _, hookName := range handler.HookNames() {
cmd.AddCommand(newAgentHookVerbCmdWithLogging(agentName, hookName))
}
return cmd
}
// getHookType returns the hook type based on the hook name.
// Returns "subagent" for task-related hooks (pre-task, post-task, post-todo),
// "tool" for tool-related hooks (before-tool, after-tool),
// "agent" for all other agent hooks.
func getHookType(hookName string) string {
switch hookName {
case claudecode.HookNamePreTask, claudecode.HookNamePostTask, claudecode.HookNamePostTodo:
return "subagent"
case geminicli.HookNameBeforeTool, geminicli.HookNameAfterTool:
return "tool"
default:
return "agent"
}
}
// executeAgentHook runs the core hook execution logic for a given agent and hook name.
// It handles git repo checks, enabled checks, logging, event parsing, and lifecycle dispatch.
// Used by both the registered subcommand path and the RunE fallback for external agents.
// When initLogging is true, it initializes and cleans up hook logging (used by the RunE fallback
// since it doesn't go through PersistentPreRunE). Built-in agent subcommands pass false since
// their parent command's PersistentPreRunE already handles logging.
func executeAgentHook(cmd *cobra.Command, agentName types.AgentName, hookName string, initLogging bool) error {
// Skip silently if not in a git repository - hooks shouldn't prevent the agent from working
if _, err := paths.WorktreeRoot(cmd.Context()); err != nil {
return nil
}
// Skip if Trace is not enabled
enabled, err := IsEnabled(cmd.Context())
if err == nil && !enabled {
return nil
}
if initLogging {
cleanup := initHookLogging(cmd.Context())
defer cleanup()
}
// Initialize logging context with agent name
ctx := logging.WithAgent(logging.WithComponent(cmd.Context(), "hooks"), agentName)
// Strategy name for logging
strategyName := strategy.StrategyNameManualCommit
hookType := getHookType(hookName)
// Start root perf span — child spans in lifecycle handlers and strategy
// methods will automatically nest under this span.
ctx, span := perf.Start(ctx, hookName,
slog.String("hook_type", hookType))
defer span.End()
logging.Debug(
ctx, "hook invoked",
slog.String("hook", hookName),
slog.String("hook_type", hookType),
slog.String("strategy", strategyName),
)
// Set the current hook agent so handlers can retrieve it
currentHookAgentName = agentName
defer func() { currentHookAgentName = "" }()
// Use the lifecycle dispatcher for all hooks
var hookErr error
ag, agentErr := agent.Get(agentName)
if agentErr != nil {
return fmt.Errorf("failed to get agent %q: %w", agentName, agentErr)
}
handler, ok := agent.AsHookSupport(ag)
if !ok {
return fmt.Errorf("agent %q does not support hooks", agentName)
}
// Use cmd.InOrStdin() to support testing with cmd.SetIn()
event, parseErr := handler.ParseHookEvent(ctx, hookName, cmd.InOrStdin())
if parseErr != nil {
return fmt.Errorf("failed to parse hook event: %w", parseErr)
}
if event != nil {
// Lifecycle event — use the generic dispatcher
hookErr = DispatchLifecycleEvent(ctx, ag, event)
} else if agentName == agent.AgentNameClaudeCode && hookName == claudecode.HookNamePostTodo {
// PostTodo is Claude-specific: creates incremental checkpoints during subagent execution
hookErr = handleClaudeCodePostTodo(ctx)
}
// Other pass-through hooks (nil event, no special handling) are no-ops
span.RecordError(hookErr)
return hookErr
}
// newAgentHookVerbCmdWithLogging creates a command for a specific hook verb with structured logging.
// It uses the lifecycle dispatcher (ParseHookEvent → DispatchLifecycleEvent) as the primary path.
// PostTodo is handled directly as it's Claude-specific and not part of the lifecycle dispatcher.
func newAgentHookVerbCmdWithLogging(agentName types.AgentName, hookName string) *cobra.Command {
return &cobra.Command{
Use: hookName,
Hidden: true,
Short: "Called on " + hookName,
RunE: func(cmd *cobra.Command, _ []string) error {
return executeAgentHook(cmd, agentName, hookName, false)
},
}
}