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

156 lines
5.2 KiB
Go

package hooks
import (
"encoding/json"
"io"
"os"
"strings"
"github.com/zzet/gortex/internal/modelhint"
)
// Mode selects the posture of the Claude Code PreToolUse / PostToolUse
// integration. Two modes are supported:
//
// - ModeDeny (default, "deny"): the PreToolUse hook actively denies
// whole-file Grep / Glob / Read of indexed source and points the
// agent at the equivalent Gortex graph tools. The agent learns by
// friction. No PostToolUse hook is registered.
//
// - ModeEnrich ("enrich"): the PreToolUse hook never denies — it only
// emits soft `additionalContext` guidance — and a PostToolUse hook
// augments the tool's actual output with graph context (enclosing
// symbols, file metadata, callers). Easier onboarding for teams that
// don't want their agent stopped mid-flow; the graph value still
// lands, just adjacent to the raw output rather than as a redirect.
//
// - ModeConsultUnlock ("consult-unlock"): the PreToolUse hook denies
// Read / Grep / Glob fallback only until the agent has queried the
// Gortex graph at least once this session. The first `mcp__gortex__*`
// tool call flips a per-session marker; from then on the deny is
// downgraded to soft `additionalContext` (same as ModeEnrich). The
// posture nudges the agent to consult the graph before falling back
// to raw file reads, then gets out of the way.
//
// - ModeAdaptiveNudge ("nudge"): instead of denying every fallback
// call, the hook counts consecutive non-symbolic tool calls per
// session and soft-denies once when the streak crosses a threshold,
// then resets — so the reminder fires once per burst and the very
// next call proceeds. A symbolic / `mcp__gortex__*` call resets the
// streak to zero.
//
// The modes co-exist by selection — a user picks one per install via
// `gortex install --hook-mode=<mode>`. Switching modes is a single
// re-install; the Claude Code adapter rewrites the hook command and
// adds / removes the PostToolUse entry to match.
type Mode int
const (
// ModeDeny preserves the legacy "redirect by deny" behavior.
ModeDeny Mode = iota
// ModeEnrich augments tool output rather than denying it.
ModeEnrich
// ModeConsultUnlock denies fallback reads until the agent has
// consulted the Gortex graph once, then downgrades to soft context.
ModeConsultUnlock
// ModeAdaptiveNudge soft-denies once per burst of non-symbolic
// fallback calls rather than denying every call.
ModeAdaptiveNudge
)
// ParseMode resolves the string form into a Mode. Empty / unknown
// values fall through to ModeDeny so unset environments keep the
// established behavior.
func ParseMode(s string) Mode {
switch strings.ToLower(strings.TrimSpace(s)) {
case "enrich":
return ModeEnrich
case "consult-unlock":
return ModeConsultUnlock
case "nudge", "adaptive-nudge":
return ModeAdaptiveNudge
default:
return ModeDeny
}
}
// String renders the mode in its canonical lower-case form. Useful for
// logging and for round-tripping the value through the `--mode` flag.
func (m Mode) String() string {
switch m {
case ModeEnrich:
return "enrich"
case ModeConsultUnlock:
return "consult-unlock"
case ModeAdaptiveNudge:
return "nudge"
default:
return "deny"
}
}
// Run reads a single hook payload from stdin, peeks at hook_event_name,
// and dispatches to the right handler. This is the single entry point
// `.claude/settings.local.json` should register via `gortex hook`.
//
// Any error in reading or parsing results in a silent no-op — hooks
// must never block Claude Code's normal flow.
func Run(port int, mode Mode) {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return
}
var peek struct {
HookEventName string `json:"hook_event_name"`
}
if err := json.Unmarshal(data, &peek); err != nil {
return
}
switch peek.HookEventName {
case "PreToolUse":
captureModelHint(data)
runPreToolUse(data, port, mode)
case "PostToolUse":
runPostToolUse(data)
case "PreCompact":
runPreCompact(data, port)
case "Stop":
runPostTask(data, port)
case "SessionStart":
captureModelHint(data)
runSessionStart(data)
case "UserPromptSubmit":
runUserPromptSubmit(data)
}
}
// captureModelHint extracts the active LLM model from a hook payload and
// records it through the model-hint bridge, so the (separate-process)
// daemon's savings recorder can attribute per-call token savings to the
// model that drove the call. The model arrives two ways: SessionStart
// carries it as a top-level `model` field, while PreToolUse does not —
// there we read the most recent assistant turn from the session
// transcript, which also catches a mid-session `/model` switch. Entirely
// best-effort: any gap leaves the model unknown and savings stay
// provider-neutral.
func captureModelHint(data []byte) {
var in struct {
CWD string `json:"cwd"`
Model string `json:"model"`
TranscriptPath string `json:"transcript_path"`
}
if json.Unmarshal(data, &in) != nil {
return
}
model := strings.TrimSpace(in.Model)
if model == "" && in.TranscriptPath != "" {
model = modelFromTranscript(in.TranscriptPath)
}
if model == "" {
return
}
modelhint.Write(in.CWD, model, "claude-code")
}