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
321 lines
11 KiB
Go
321 lines
11 KiB
Go
package hooks
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
"github.com/zzet/gortex/internal/pathkey"
|
|
"github.com/zzet/gortex/internal/profiles"
|
|
"github.com/zzet/gortex/internal/toolref"
|
|
)
|
|
|
|
// SessionStartInput is the JSON Claude Code sends on SessionStart. We
|
|
// only consume the fields we use; unknown fields are ignored.
|
|
type SessionStartInput struct {
|
|
HookEventName string `json:"hook_event_name"`
|
|
SessionID string `json:"session_id"`
|
|
TranscriptPath string `json:"transcript_path"`
|
|
CWD string `json:"cwd"`
|
|
// Source is "startup" | "resume" | "clear" | "compact". Currently
|
|
// unused — every source gets the same orientation block — but kept
|
|
// here so future logic can branch.
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
// runSessionStart handles a SessionStart hook by querying the daemon
|
|
// for status and emitting an additionalContext block. The block is
|
|
// appended to the session's system prompt and visible for every turn,
|
|
// so it's the strongest "rule restated" surface we have.
|
|
//
|
|
// Graceful degradation: if the daemon socket can't be dialled, the
|
|
// hook still emits a block — but its content tells the user that
|
|
// enforcement is disabled and how to fix it.
|
|
func runSessionStart(data []byte) {
|
|
var input SessionStartInput
|
|
if err := json.Unmarshal(data, &input); err != nil {
|
|
return
|
|
}
|
|
if input.HookEventName != "SessionStart" {
|
|
return
|
|
}
|
|
|
|
ctx := buildSessionStartBriefing(input.CWD)
|
|
if ctx == "" {
|
|
return
|
|
}
|
|
|
|
output := HookOutput{
|
|
HookSpecificOutput: &HookSpecificOutput{
|
|
HookEventName: "SessionStart",
|
|
AdditionalContext: ctx,
|
|
},
|
|
}
|
|
out, err := json.Marshal(output)
|
|
if err != nil {
|
|
return
|
|
}
|
|
fmt.Print(string(out))
|
|
}
|
|
|
|
// sessionStartStatusFn is the seam tests use to inject a fake daemon
|
|
// status without spinning up a real socket. Production reads the
|
|
// default (queries the daemon socket directly via Control RPC).
|
|
var sessionStartStatusFn = fetchDaemonStatus
|
|
|
|
// fetchDaemonStatus dials the daemon's control socket and asks for
|
|
// status. Returns errDaemonUnreachable when the socket is missing —
|
|
// every other error is propagated so callers can surface it.
|
|
func fetchDaemonStatus() (*daemon.StatusResponse, error) {
|
|
client, err := daemon.Dial(daemon.Handshake{
|
|
Mode: daemon.ModeControl,
|
|
ClientName: "gortex-hook-sessionstart",
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, daemon.ErrDaemonUnavailable) {
|
|
return nil, errDaemonUnreachable
|
|
}
|
|
return nil, err
|
|
}
|
|
defer client.Close()
|
|
|
|
_ = client.Conn.SetDeadline(time.Now().Add(2 * time.Second))
|
|
|
|
resp, err := client.Control(daemon.ControlStatus, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !resp.OK {
|
|
return nil, fmt.Errorf("daemon rejected status: %s", resp.ErrorMsg)
|
|
}
|
|
|
|
var status daemon.StatusResponse
|
|
if err := unmarshalResult(resp.Result, &status); err != nil {
|
|
return nil, err
|
|
}
|
|
return &status, nil
|
|
}
|
|
|
|
// buildSessionStartBriefing assembles the additionalContext block. It
|
|
// always emits something (even when the daemon is down) so the agent
|
|
// learns the rule applies regardless of enforcement state.
|
|
func buildSessionStartBriefing(cwd string) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("## Gortex Session Orientation\n\n")
|
|
|
|
status, err := sessionStartStatusFn()
|
|
switch {
|
|
case errors.Is(err, errDaemonUnreachable):
|
|
sb.WriteString("⚠️ **Gortex daemon is not running.** Code-operation enforcement is disabled for this session: Read/Grep/Glob/Bash on indexed source files will not be redirected to graph tools.\n\n")
|
|
sb.WriteString("Start it with: `gortex daemon start --detach`\n\n")
|
|
sb.WriteString(rulePreamble())
|
|
return sb.String()
|
|
case err != nil:
|
|
// Unexpected error — surface it tersely so debugging is possible.
|
|
fmt.Fprintf(&sb, "⚠️ Gortex daemon status query failed: %v. Continuing with rule-only enforcement.\n\n", err)
|
|
sb.WriteString(rulePreamble())
|
|
return sb.String()
|
|
}
|
|
|
|
// Happy path: daemon is reachable. The lean hook tier (set by the
|
|
// active instruction profile) compresses the status prose to one
|
|
// line; the rule preamble — the positioning cues — survives every
|
|
// tier, and so does the actionable not-covered warning.
|
|
if activeHookTier() == profiles.HookTierLean {
|
|
sb.WriteString(renderLeanReadiness(cwd, status))
|
|
} else {
|
|
sb.WriteString(renderDaemonReadiness(status))
|
|
sb.WriteString(renderCwdCoverage(cwd, status))
|
|
}
|
|
sb.WriteString("\n")
|
|
sb.WriteString(rulePreamble())
|
|
return sb.String()
|
|
}
|
|
|
|
// activeHookTier reads the machine's hook-verbosity tier from the
|
|
// active instruction profile. Package var so tests pin a tier without
|
|
// touching machine state.
|
|
var activeHookTier = profiles.ActiveHookTier
|
|
|
|
// renderLeanReadiness is the one-line status the lean tier emits when
|
|
// the cwd is a tracked repo. The workspace-root and not-covered cases
|
|
// keep their full explanations in every tier — those are actionable
|
|
// warnings, not status prose.
|
|
func renderLeanReadiness(cwd string, s *daemon.StatusResponse) string {
|
|
var totalNodes int
|
|
for _, r := range s.TrackedRepos {
|
|
totalNodes += r.Nodes
|
|
}
|
|
state := "ready"
|
|
if !s.Ready {
|
|
state = "warming up — enforcement partial"
|
|
}
|
|
line := fmt.Sprintf("✓ Gortex %s (v%s): %d repo(s), %d nodes.",
|
|
state, strings.TrimPrefix(s.Version, "v"), len(s.TrackedRepos), totalNodes)
|
|
|
|
abs := cwd
|
|
if cwd != "" {
|
|
if a, err := filepath.Abs(cwd); err == nil {
|
|
abs = a
|
|
}
|
|
}
|
|
if abs != "" {
|
|
if exact, _ := classifyCwd(abs, s.TrackedRepos); exact != nil {
|
|
return fmt.Sprintf("%s cwd tracked as `%s` — enforcement active.\n", line, exact.Name)
|
|
}
|
|
// Workspace-root and not-covered explanations stay verbatim.
|
|
return line + "\n\n" + renderCwdCoverage(cwd, s)
|
|
}
|
|
return line + "\n"
|
|
}
|
|
|
|
// renderDaemonReadiness summarises the daemon's overall state in one
|
|
// short paragraph: version, uptime, ready/warmup, totals.
|
|
func renderDaemonReadiness(s *daemon.StatusResponse) string {
|
|
var totalNodes, totalEdges, totalRepos int
|
|
totalRepos = len(s.TrackedRepos)
|
|
for _, r := range s.TrackedRepos {
|
|
totalNodes += r.Nodes
|
|
totalEdges += r.Edges
|
|
}
|
|
|
|
var sb strings.Builder
|
|
switch {
|
|
case s.Ready && s.EnrichmentComplete:
|
|
fmt.Fprintf(&sb, "✓ Gortex daemon ready (v%s, uptime %s). ", s.Version, formatDuration(s.UptimeSeconds))
|
|
case s.Ready:
|
|
fmt.Fprintf(&sb, "✓ Gortex daemon ready — references queryable (v%s, uptime %s); semantic enrichment still running. ",
|
|
s.Version, formatDuration(s.UptimeSeconds))
|
|
default:
|
|
fmt.Fprintf(&sb, "⏳ Gortex daemon warming up (v%s, %s elapsed). Enforcement is partial until ready. ",
|
|
s.Version, formatDuration(s.WarmupSeconds))
|
|
}
|
|
fmt.Fprintf(&sb, "%d tracked repo(s), %d nodes, %d edges across %d workspace(s).\n\n",
|
|
totalRepos, totalNodes, totalEdges, len(s.Workspaces))
|
|
return sb.String()
|
|
}
|
|
|
|
// renderCwdCoverage tells the user whether the cwd is covered by a
|
|
// tracked repo, by a workspace root containing tracked repos, or
|
|
// neither. The third case is the actionable one — we tell them how
|
|
// to fix it without doing anything ourselves.
|
|
func renderCwdCoverage(cwd string, s *daemon.StatusResponse) string {
|
|
if cwd == "" {
|
|
return ""
|
|
}
|
|
abs, err := filepath.Abs(cwd)
|
|
if err != nil {
|
|
abs = cwd
|
|
}
|
|
|
|
exact, contained := classifyCwd(abs, s.TrackedRepos)
|
|
switch {
|
|
case exact != nil:
|
|
return fmt.Sprintf("**cwd `%s` is tracked** as repo `%s` (workspace: `%s`, %d nodes). Enforcement is active.\n",
|
|
abs, exact.Name, exact.Workspace, exact.Nodes)
|
|
case len(contained) > 0:
|
|
// cwd is a parent of one or more tracked repos.
|
|
names := make([]string, 0, len(contained))
|
|
for _, r := range contained {
|
|
names = append(names, r.Name)
|
|
}
|
|
sort.Strings(names)
|
|
shown := names
|
|
extra := 0
|
|
if len(shown) > 8 {
|
|
shown = shown[:8]
|
|
extra = len(names) - 8
|
|
}
|
|
summary := strings.Join(shown, ", ")
|
|
if extra > 0 {
|
|
summary = fmt.Sprintf("%s, +%d more", summary, extra)
|
|
}
|
|
example := "<repo>"
|
|
if len(names) > 0 {
|
|
example = names[0]
|
|
}
|
|
return fmt.Sprintf("**cwd `%s` is a workspace root** containing %d tracked repo(s): %s. "+
|
|
"Enforcement is active for files inside these repos.\n\n"+
|
|
"This cwd is not itself a tracked repo, so a tool call with no explicit scope fans out across "+
|
|
"all %d repos. To target one repo, prefix file paths with the repo name "+
|
|
"(e.g. `%s/path/to/file.go`) or pass an explicit `repo:` filter.\n",
|
|
abs, len(contained), summary, len(contained), example)
|
|
default:
|
|
return fmt.Sprintf("⚠️ **cwd `%s` is not covered by any tracked repo.** Read/Grep/Glob/Bash will fall through to soft guidance only — graph tools won't be available for this directory.\n\nTo enable enforcement: `gortex track %s`\n",
|
|
abs, abs)
|
|
}
|
|
}
|
|
|
|
// classifyCwd partitions the relationship between cwd and the daemon's
|
|
// tracked repos. Returns either an exact match (cwd == repo path) or
|
|
// the list of tracked repos contained under cwd (workspace-root case).
|
|
func classifyCwd(cwd string, repos []daemon.TrackedRepoStatus) (exact *daemon.TrackedRepoStatus, contained []daemon.TrackedRepoStatus) {
|
|
cwd = filepath.Clean(cwd)
|
|
for i := range repos {
|
|
repo := repos[i]
|
|
repoPath := filepath.Clean(repo.Path)
|
|
if pathkey.EqualPaths(repoPath, cwd) {
|
|
exact = &repos[i]
|
|
continue
|
|
}
|
|
if hasPathPrefix(repoPath, cwd) {
|
|
contained = append(contained, repo)
|
|
}
|
|
}
|
|
return exact, contained
|
|
}
|
|
|
|
// hasPathPrefix reports whether path is rooted at prefix. It delegates to
|
|
// pathkey.HasPathPrefix, which handles component boundaries (`/foo/barbaz`
|
|
// must not match `/foo/bar`), the separator-root edge, and — on a
|
|
// case-insensitive filesystem — case-only differences between the cwd and
|
|
// a tracked root (#277).
|
|
func hasPathPrefix(path, prefix string) bool {
|
|
return pathkey.HasPathPrefix(path, prefix)
|
|
}
|
|
|
|
// rulePreamble is the short, always-present rule restatement. The
|
|
// full table lives in ~/.claude/CLAUDE.md (added by gortex install)
|
|
// — this is just enough that an agent in the very first turn knows
|
|
// to reach for graph tools first.
|
|
func rulePreamble() string {
|
|
return "**Rule:** Use Gortex MCP tools for code operations in this repo. Prefer:\n" +
|
|
"- **`explore` first for any task or bug report** — one call returns the ranked neighborhood (likely symbols + their source + call paths + the files to change); answer or start editing from it\n" +
|
|
"- `search_symbols` / `find_usages` / `get_callers` over `grep` / `Grep`\n" +
|
|
"- `get_symbol_source` / `batch_symbols` / `get_file_summary` over `Read`\n" +
|
|
"- `edit_symbol` / `edit_file` / `rename_symbol` over `Edit` / `Write` for indexed source\n\n" +
|
|
"Pre-tool hooks will deny attempts to Read/Grep/Glob indexed source files; the deny message names the right tool.\n" +
|
|
"Shell only (no MCP tools)? Reach any tool with `gortex call <tool> --arg k=v` (e.g. `" + toolref.CLIFallback("get_symbol_source") + "`) — there is no bare `gortex <tool>` verb.\n"
|
|
}
|
|
|
|
// formatDuration renders a number of seconds as "1h7m" or "45s".
|
|
// Tighter than time.Duration.String() and avoids "0s" tails.
|
|
func formatDuration(secs int64) string {
|
|
if secs <= 0 {
|
|
return "0s"
|
|
}
|
|
d := time.Duration(secs) * time.Second
|
|
hours := int(d / time.Hour)
|
|
mins := int((d % time.Hour) / time.Minute)
|
|
s := int((d % time.Minute) / time.Second)
|
|
switch {
|
|
case hours > 0:
|
|
if mins > 0 {
|
|
return fmt.Sprintf("%dh%dm", hours, mins)
|
|
}
|
|
return fmt.Sprintf("%dh", hours)
|
|
case mins > 0:
|
|
if s > 0 {
|
|
return fmt.Sprintf("%dm%ds", mins, s)
|
|
}
|
|
return fmt.Sprintf("%dm", mins)
|
|
default:
|
|
return fmt.Sprintf("%ds", s)
|
|
}
|
|
}
|