Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

151 lines
5.5 KiB
Go

package hooks
import (
"encoding/json"
"io"
"os"
)
// pi.go is the Go side of the Pi (earendil-works/pi) integration. Pi has
// no MCP support — the Gortex Pi extension (shipped by the `pi` agent
// adapter) is a thin TypeScript bridge that, on each relevant lifecycle
// event, shells `gortex hook --agent=pi`, writes a normalized event
// envelope to stdin, and applies the decision it reads back from stdout.
//
// Keeping the decision logic here (rather than re-implementing it in
// TypeScript) means the deny / enrich / consult-unlock / nudge postures
// and the indexed-source classification stay in one place.
//
// The wire contract is owned end-to-end by Gortex (this file plus the
// embedded extension/index.ts), so it is deliberately minimal and already
// normalized into the canonical Claude-Code tool vocabulary: the
// extension maps Pi's own tool names and input keys onto Claude-Code's
// tool names and the `file_path` / `pattern` / `command` shape that
// `enrich` switches on, before sending so `enrich` can be reused unchanged.
// PiEvent is the normalized envelope the Pi extension writes to stdin.
type PiEvent struct {
// Event is the lifecycle phase: "tool_call", "session_start",
// "before_agent_start", "tool_result", or "before_compact".
Event string `json:"event"`
// ToolName / ToolInput carry a tool_call, already mapped into the
// canonical Claude-Code vocabulary by the extension.
ToolName string `json:"tool_name,omitempty"`
ToolInput map[string]any `json:"tool_input,omitempty"`
// CWD is Pi's working directory — drives cwd-coverage messaging and
// the file-indexed lookup inside enrich.
CWD string `json:"cwd,omitempty"`
// SessionID keys the per-session state store (consult-unlock marker,
// nudge streak). Best-effort: empty is tolerated (those postures then
// degrade to a fresh zero-value state).
SessionID string `json:"session_id,omitempty"`
// IsGortexTool is set by the extension when the call targets one of
// the Gortex graph tools it registered. Pi gives those plain names
// (e.g. "search_symbols"), not the `mcp__gortex__` prefix Claude
// uses, so the extension flags them explicitly. Lets the
// consult-unlock handshake and the nudge streak-reset recognise a
// graph query the same way the in-process Claude hook does.
IsGortexTool bool `json:"is_gortex_tool,omitempty"`
}
// PiDecision is what RunPi writes to stdout. The extension applies it:
// Block→deny the tool call with Reason; AdditionalContext→inject a soft
// tip; Orientation→inject as a tail user message before the next LLM call.
// All fields are optional; an empty decision means "do nothing" (fail-open).
type PiDecision struct {
Block bool `json:"block,omitempty"`
Reason string `json:"reason,omitempty"`
AdditionalContext string `json:"additional_context,omitempty"`
Orientation string `json:"orientation,omitempty"`
}
// RunPi reads a single Pi event envelope from stdin, dispatches on the
// event phase, and writes a PiDecision to stdout. Any read/parse error is
// a silent no-op (emits an empty decision) — a hook must never break the
// host agent's flow.
func RunPi(port int, mode Mode) {
data, err := io.ReadAll(os.Stdin)
if err != nil {
emitPiDecision(PiDecision{})
return
}
emitPiDecision(handlePi(data, port, mode))
}
// handlePi is the testable core: parse the envelope, route by phase,
// return the decision. Kept separate from RunPi so tests can drive it
// with bytes and assert on the returned struct without touching stdio.
func handlePi(data []byte, port int, mode Mode) PiDecision {
var ev PiEvent
if err := json.Unmarshal(data, &ev); err != nil {
return PiDecision{}
}
switch ev.Event {
case "tool_call":
return piToolCall(ev, port, mode)
case "session_start", "before_agent_start", "before_compact":
// All three are "inject the orientation" moments; the extension
// injects the briefing as a tail user message.
ctx := buildSessionStartBriefing(ev.CWD)
if ctx == "" {
return PiDecision{}
}
return PiDecision{Orientation: ctx}
default:
return PiDecision{}
}
}
// piToolCall maps a normalized Pi tool_call onto the shared enrich +
// applyMode pipeline and lowers the resulting enrichResult into a
// PiDecision.
func piToolCall(ev PiEvent, port int, mode Mode) PiDecision {
input := HookInput{
HookEventName: "PreToolUse",
ToolName: ev.ToolName,
ToolInput: ev.ToolInput,
CWD: ev.CWD,
SessionID: ev.SessionID,
}
// A Gortex graph tool call is the "symbolic" call the postures key
// off. Under consult-unlock it flips the per-session marker that
// unlocks fallback reads; it is always allowed through, so there is
// nothing else to decide.
isGortexTool := ev.IsGortexTool
if isGortexTool {
if mode == ModeConsultUnlock {
markGraphConsulted(ev.SessionID)
}
// Still let adaptive-nudge reset its streak on a symbolic call.
if mode == ModeAdaptiveNudge {
_ = applyMode(input, true, mode, enrichResult{})
}
return PiDecision{}
}
result := applyMode(input, isGortexTool, mode, enrich(input, port))
if result.deny {
return PiDecision{Block: true, Reason: result.reason}
}
if result.context != "" {
return PiDecision{AdditionalContext: result.context}
}
return PiDecision{}
}
// emitPiDecision marshals a PiDecision to stdout. A marshal failure is
// swallowed — the extension treats empty/garbled output as a no-op.
func emitPiDecision(d PiDecision) {
out, err := json.Marshal(d)
if err != nil {
return
}
_, _ = os.Stdout.Write(out)
}