Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 6 additions & 6 deletions cmd/hookflow/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4183,7 +4183,7 @@ stdoutR, stdoutW, _ := os.Pipe()
os.Stdout = stdoutW

escapedDir := strings.ReplaceAll(tmpDir, `\`, `\\`)
_ = runWithRawInput(tmpDir, `{"toolName":"create","toolArgs":{"path":"test.txt","file_text":"hello"},"cwd":"`+escapedDir+`"}`, "pre", true)
_ = runWithRawInput(tmpDir, `{"toolName":"create","toolArgs":{"path":"test.txt","file_text":"hello"},"cwd":"`+escapedDir+`"}`, "pre", "", true)

_ = stdoutW.Close()
os.Stdout = oldStdout
Expand Down Expand Up @@ -4233,7 +4233,7 @@ stdoutR, stdoutW, _ := os.Pipe()
os.Stdout = stdoutW

escapedDir := strings.ReplaceAll(tmpDir, `\`, `\\`)
_ = runWithRawInput(tmpDir, `{"toolName":"create","toolArgs":{"path":"test.txt","file_text":"hello"},"cwd":"`+escapedDir+`"}`, "pre", true)
_ = runWithRawInput(tmpDir, `{"toolName":"create","toolArgs":{"path":"test.txt","file_text":"hello"},"cwd":"`+escapedDir+`"}`, "pre", "", true)

_ = stdoutW.Close()
os.Stdout = oldStdout
Expand Down Expand Up @@ -4281,7 +4281,7 @@ stdoutR, stdoutW, _ := os.Pipe()
os.Stdout = stdoutW

escapedDir := strings.ReplaceAll(tmpDir, `\`, `\\`)
_ = runWithRawInput(tmpDir, `{"toolName":"create","toolArgs":{"path":"test.txt","file_text":"hello"},"cwd":"`+escapedDir+`"}`, "pre", true)
_ = runWithRawInput(tmpDir, `{"toolName":"create","toolArgs":{"path":"test.txt","file_text":"hello"},"cwd":"`+escapedDir+`"}`, "pre", "", true)

_ = stdoutW.Close()
os.Stdout = oldStdout
Expand Down Expand Up @@ -4522,7 +4522,7 @@ os.Stdout = stdoutW

// Pass processCwd as dir (simulating os.Getwd() from plugin context)
// but hook input cwd is repoDir (the actual repo root)
_ = runWithRawInput(processCwd, `{"toolName":"create","toolArgs":{"path":"test.txt","file_text":"hello"},"cwd":"`+escapedRepoDir+`"}`, "pre", true)
_ = runWithRawInput(processCwd, `{"toolName":"create","toolArgs":{"path":"test.txt","file_text":"hello"},"cwd":"`+escapedRepoDir+`"}`, "pre", "", true)

_ = stdoutW.Close()
os.Stdout = oldStdout
Expand Down Expand Up @@ -4707,7 +4707,7 @@ func TestComplianceExemptionWithStringToolArgs(t *testing.T) {
stdoutR, stdoutW, _ := os.Pipe()
os.Stdout = stdoutW

_ = runWithRawInput(tmpDir, stringToolArgsInput, "pre", true)
_ = runWithRawInput(tmpDir, stringToolArgsInput, "pre", "", true)

_ = stdoutW.Close()
os.Stdout = oldStdout
Expand Down Expand Up @@ -5356,7 +5356,7 @@ func TestTranscriptRecording_AppendsOnRawInput(t *testing.T) {
stdoutR, stdoutW, _ := os.Pipe()
os.Stdout = stdoutW

_ = runWithRawInput(tmpDir, rawInput, "pre", false)
_ = runWithRawInput(tmpDir, rawInput, "pre", "", false)

_ = stdoutW.Close()
os.Stdout = oldStdout
Expand Down
85 changes: 72 additions & 13 deletions cmd/hookflow/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ Use --event to pass a pre-built event JSON (legacy mode).`,

// If --raw flag is set, use the new event detection
if raw {
return runWithRawInput(dir, eventStr, lifecycle, global)
return runWithRawInput(dir, eventStr, lifecycle, eventType, global)
}

// Legacy mode: pre-built event JSON
Expand All @@ -290,11 +290,23 @@ var triggersCmd = &cobra.Command{
Short: "List available trigger types",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Available trigger types:")
fmt.Println(" hooks - Agent hook events (preToolUse, postToolUse)")
fmt.Println(" hooks - Agent hook events (all 13 Copilot CLI events)")
fmt.Println(" preToolUse, postToolUse, postToolUseFailure,")
fmt.Println(" sessionStart, sessionEnd, agentStop,")
fmt.Println(" subagentStart, subagentStop, permissionRequest,")
fmt.Println(" notification, preCompact, errorOccurred,")
fmt.Println(" userPromptSubmitted")
fmt.Println(" tool - Tool-specific triggers with argument filtering")
fmt.Println(" file - File create/edit events")
fmt.Println(" commit - Git commit events")
fmt.Println(" push - Git push events")
fmt.Println()
fmt.Println("Hookify aliases (for .md rules):")
fmt.Println(" bash - Shell tool events (powershell, bash, shell, terminal)")
fmt.Println(" file - File tool events (create, edit)")
fmt.Println(" all - Matches any event type")
fmt.Println(" stop - agentStop and subagentStop events")
fmt.Println(" prompt - userPromptSubmitted events")
},
}

Expand All @@ -320,7 +332,7 @@ func init() {
runCmd.Flags().StringP("workflow", "w", "", "Specific workflow to run")
runCmd.Flags().StringP("dir", "d", "", "Directory to search (default: current directory)")
runCmd.Flags().BoolP("raw", "r", false, "Accept raw hook input and auto-detect event type")
runCmd.Flags().StringP("event-type", "t", "preToolUse", "Hook event type: preToolUse or postToolUse")
runCmd.Flags().StringP("event-type", "t", "preToolUse", "Hook event type (preToolUse, postToolUse, postToolUseFailure, sessionStart, sessionEnd, agentStop, subagentStart, subagentStop, permissionRequest, notification, preCompact, errorOccurred, userPromptSubmitted)")
runCmd.Flags().Bool("global", false, "Running from global/plugin hooks (skips if repo hooks already ran)")

// logs flags
Expand All @@ -329,13 +341,17 @@ func init() {
logsCmd.Flags().Bool("path", false, "Only print log path (for scripting)")
}

// eventTypeToLifecycle converts Copilot hook event type to workflow lifecycle
// eventTypeToLifecycle converts Copilot hook event type to workflow lifecycle.
// All 13 Copilot CLI hook events map to either "pre" or "post":
// - preToolUse, sessionStart, subagentStart, permissionRequest, preCompact,
// agentStop, userPromptSubmitted, notification, errorOccurred → "pre"
// - postToolUse, postToolUseFailure, sessionEnd, subagentStop → "post"
func eventTypeToLifecycle(eventType string) string {
switch eventType {
case "postToolUse", "post":
case "postToolUse", "postToolUseFailure", "sessionEnd", "subagentStop", "post":
return "post"
default:
return "pre" // preToolUse, pre, or any unknown defaults to pre
return "pre"
}
}

Expand Down Expand Up @@ -363,7 +379,7 @@ func runWorkflow(dir, workflowName string) error {
}

// runWithRawInput handles raw Copilot hook input and auto-detects event type
func runWithRawInput(dir, inputStr, lifecycle string, global bool) error {
func runWithRawInput(dir, inputStr, lifecycle, hookEventType string, global bool) error {
log := logging.Context("run")
done := logging.StartOperation("runWithRawInput", "dir="+dir, "lifecycle="+lifecycle, fmt.Sprintf("global=%v", global))

Expand Down Expand Up @@ -508,13 +524,17 @@ func runWithRawInput(dir, inputStr, lifecycle string, global bool) error {
// Set lifecycle from CLI flag
evt.Lifecycle = lifecycle

// Populate Hook event — every raw hook invocation is a hook event
hookType := "preToolUse"
if lifecycle == "post" {
hookType = "postToolUse"
// Populate Hook event — every raw hook invocation is a hook event.
// Use the actual event type from the --event-type flag (not just pre/post lifecycle).
if hookEventType == "" {
// Fall back to lifecycle-based inference for backward compatibility
hookEventType = "preToolUse"
if lifecycle == "post" {
hookEventType = "postToolUse"
}
}
evt.Hook = &schema.HookEvent{
Type: hookType,
Type: hookEventType,
Cwd: evt.Cwd,
}
if evt.Tool != nil {
Expand All @@ -530,6 +550,31 @@ func runWithRawInput(dir, inputStr, lifecycle string, global bool) error {
}
}

// For non-tool events, store the full payload for field extraction
switch hookEventType {
case "sessionStart", "sessionEnd", "agentStop", "subagentStart", "subagentStop",
"permissionRequest", "notification", "preCompact", "errorOccurred", "userPromptSubmitted":
var payload map[string]interface{}
if err := json.Unmarshal(input, &payload); err == nil {
evt.Hook.Payload = payload
}
// Also populate Tool with the payload data for field extraction compatibility
if evt.Tool == nil {
evt.Tool = &schema.ToolEvent{
Name: hookEventType,
Args: make(map[string]interface{}),
}
if err := json.Unmarshal(input, &evt.Tool.Args); err != nil {
evt.Tool.Args = make(map[string]interface{})
}
}
}

// Store session ID on the event
if raw.SessionID != "" {
evt.SessionID = raw.SessionID
}

log.Debug("detected event: file=%v, tool=%v, commit=%v, push=%v, hook=%v, lifecycle=%s", evt.File != nil, evt.Tool != nil, evt.Commit != nil, evt.Push != nil, evt.Hook != nil, lifecycle)

// ── Transcript recording ────────────────────────────────────────
Expand Down Expand Up @@ -847,17 +892,26 @@ func runMatchingWorkflowsWithEvent(dir string, evt *schema.Event, global bool) e
// Aggregate results — deny wins across both hookify and YAML workflows
var finalResult *schema.WorkflowResult
var warnReasons []string
var additionalContexts []string

// Check hookify results first (already evaluated — pure Go, no shell)
for _, result := range hookifyResults {
if result.PermissionDecision == "deny" {
log.Warn("hookify rule denied: %s", result.PermissionDecisionReason)
// ContinueAgent is a special deny that forces agent continuation (agentStop override)
if result.ContinueAgent {
log.Info("hookify rule forcing agent continuation: %s", result.PermissionDecisionReason)
} else {
log.Warn("hookify rule denied: %s", result.PermissionDecisionReason)
}
return outputWorkflowResult(result)
}
log.Debug("hookify rule allowed/warned")
if result.PermissionDecisionReason != "" {
warnReasons = append(warnReasons, result.PermissionDecisionReason)
}
if result.AdditionalContext != "" {
additionalContexts = append(additionalContexts, result.AdditionalContext)
}
finalResult = result
}

Expand Down Expand Up @@ -894,6 +948,11 @@ func runMatchingWorkflowsWithEvent(dir string, evt *schema.Event, global bool) e
finalResult.PermissionDecisionReason = strings.Join(warnReasons, "\n")
}

// Merge all additionalContext from inject rules
if len(additionalContexts) > 0 && finalResult != nil {
finalResult.AdditionalContext = strings.Join(additionalContexts, "\n\n")
}

return outputWorkflowResult(finalResult)
}

Expand Down
13 changes: 7 additions & 6 deletions internal/ai/ai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ func TestNewClient(t *testing.T) {
c := NewClient()
if c == nil {
t.Fatal("NewClient returned nil")
}
if c.started {
t.Error("new client should not be started")
}
if c.client != nil {
t.Error("new client should have nil underlying client")
} else {
if c.started {
t.Error("new client should not be started")
}
if c.client != nil {
t.Error("new client should have nil underlying client")
}
}
}

Expand Down
Loading
Loading