83 lines
3.6 KiB
Go
83 lines
3.6 KiB
Go
// This file defines the process contract between pigo and a user hook command:
|
|
// what pigo writes to the hook's stdin (HookInput), what pigo reads back from
|
|
// its stdout (HookOutput), and the internal merged decision the dispatcher
|
|
// produces (HookDecision). It also defines the exit-code semantics parsing.
|
|
//
|
|
// The wire contract follows Claude Code: exit 0 = allow, exit 2 = block (with
|
|
// stderr as the reason), any other non-zero = execution failure. On exit 0 a
|
|
// well-formed JSON stdout is parsed as HookOutput; a non-JSON stdout is a no-op.
|
|
package hooks
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
)
|
|
|
|
// HookInput is the JSON payload pigo writes to a hook command's stdin. It
|
|
// carries only observable, non-secret fields (FR-17) — never API keys or
|
|
// credentials. Per-event fields are omitempty so a payload only contains the
|
|
// fields relevant to its event type.
|
|
type HookInput struct {
|
|
EventType string `json:"event_type"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
ProjectDir string `json:"project_dir,omitempty"`
|
|
ToolName string `json:"tool_name,omitempty"` // Pre/PostToolUse
|
|
ToolInput json.RawMessage `json:"tool_input,omitempty"` // Pre/PostToolUse
|
|
ToolResponse json.RawMessage `json:"tool_response,omitempty"` // PostToolUse
|
|
Prompt string `json:"prompt,omitempty"` // UserPromptSubmit
|
|
StopReason string `json:"stop_reason,omitempty"` // Stop/SessionEnd
|
|
Source string `json:"source,omitempty"` // SessionStart (startup/resume)
|
|
Trigger string `json:"trigger,omitempty"` // PreCompact (manual/auto)
|
|
Message string `json:"message,omitempty"` // Notification
|
|
}
|
|
|
|
// HookOutput is the optional JSON a hook may print to stdout to influence the
|
|
// agent. A non-JSON stdout on exit 0 is treated as an empty HookOutput (no
|
|
// operation). Decision "block" is equivalent to exiting with code 2.
|
|
type HookOutput struct {
|
|
Decision string `json:"decision,omitempty"` // "block" | "approve" | ""
|
|
Reason string `json:"reason,omitempty"`
|
|
AdditionalContext string `json:"additionalContext,omitempty"`
|
|
Continue *bool `json:"continue,omitempty"`
|
|
UpdatedInput json.RawMessage `json:"updatedInput,omitempty"` // PreToolUse: rewrite tool args
|
|
}
|
|
|
|
// blocks reports whether this output requests a block. A decision of "block"
|
|
// blocks; an explicit continue=false also blocks. "approve" and the empty
|
|
// decision allow.
|
|
func (o HookOutput) blocks() bool {
|
|
if strings.EqualFold(o.Decision, "block") {
|
|
return true
|
|
}
|
|
if o.Continue != nil && !*o.Continue {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HookDecision is the dispatcher's merged result after running all matched
|
|
// hooks for one event. Block is set if any hook blocked; Reason accumulates the
|
|
// blocking reasons; AdditionalContext accumulates injected context in order;
|
|
// UpdatedInput holds the last-provided rewrite (last writer wins, §5.4).
|
|
type HookDecision struct {
|
|
Block bool
|
|
Reason string
|
|
AdditionalContext string
|
|
UpdatedInput json.RawMessage
|
|
}
|
|
|
|
// parseHookOutput parses a hook's stdout into a HookOutput. On exit 0 a
|
|
// non-JSON body is a no-op (returns the zero value, ok=false). Empty/whitespace
|
|
// stdout is also a no-op. A valid JSON object is parsed and ok is true.
|
|
func parseHookOutput(stdout []byte) (HookOutput, bool) {
|
|
trimmed := strings.TrimSpace(string(stdout))
|
|
if trimmed == "" {
|
|
return HookOutput{}, false
|
|
}
|
|
var out HookOutput
|
|
if err := json.Unmarshal([]byte(trimmed), &out); err != nil {
|
|
return HookOutput{}, false
|
|
}
|
|
return out, true
|
|
}
|