// Package profiles defines the instruction profiles a machine can run // Gortex under: named bundles of (a) an instructions body written to // ~/.gortex/instructions/.md and @-included from the agent's // rules file, (b) a tool-surface preset for the MCP server, (c) a // skills subset for skill-aware agents, and (d) a hook-verbosity tier. // // The four surfaces are generated from ONE table (Table) so they // cannot drift: the localization preset's eager tool list is the same // slice the instructions body renders its rule table from, and the // hook tier rides on the same Profile row the installer materialises. // // The package is a leaf: internal/agents (install), internal/mcp // (session tool policy), internal/hooks (verbosity) and cmd/gortex // (the `gortex instructions` verb) all import it, never the reverse. package profiles import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/zzet/gortex/internal/platform" ) // HookTier selects how much ambient text the lifecycle hooks inject // per session / per turn. Lean keeps every positioning cue (the // prefer-graph-tools rule, the deny-hook warning, the tool-name // mappings) and trims only status prose around them. type HookTier string const ( // HookTierStandard is today's hook output: full daemon readiness, // cwd coverage, rule preamble, prompt-derived symbol injection. HookTierStandard HookTier = "standard" // HookTierLean compresses status blocks to one line and caps the // per-turn prompt injection, keeping the rule cues intact. HookTierLean HookTier = "lean" ) // DefaultName is the profile installed and active when the user never // ran `gortex instructions switch`. const DefaultName = "core" // ActiveEnv overrides the on-disk active-profile state when set to a // known profile name — the seam benchmarks and CI use to pin a profile // per process tree without touching the user's machine state. const ActiveEnv = "GORTEX_INSTRUCTIONS_PROFILE" // Profile is one row of the single source-of-truth table. type Profile struct { Name string Summary string // ToolPreset is the MCP tool-surface preset sessions default to // while this profile is active. Empty keeps the server's existing // client-aware defaults (known coding agents get the `agent` // floor, everyone else the server's global preset). ToolPreset string // EagerTools, when non-nil, is the eager allow-set of the preset // named by ToolPreset. internal/mcp builds the preset from this // slice and the instructions body renders its rule rows from it — // one list, two surfaces. EagerTools []string // Skills is the subset of shipped gortex-* skills this profile // keeps installed. Nil means all shipped skills. Skills []string HookTier HookTier body func() string } // localizationEagerTools is the lean "where is the code that does X" // surface: orient, search, trace, read. It is the single source for // both the `localization` tool preset (internal/mcp) and the rule // table in the localization instructions body. var localizationEagerTools = []string{ // the one-shot localization opener "explore", // orient "smart_context", "index_health", // search "search_symbols", "search_text", // trace "find_usages", "get_callers", "find_implementations", // read — batch_symbols is load-bearing for turn economy: without a // multi-symbol read, follow-ups on a localization neighborhood cost // one turn per symbol (measured +1 median turn on the localization // benchmark when it was left out of this surface). "get_symbol_source", "batch_symbols", "get_file_summary", "read_file", } // Table returns the profile table. Callers must not mutate the // returned slices. func Table() []Profile { return []Profile{ { Name: "core", Summary: "balanced default — full workflow guidance, client-aware tool surface", HookTier: HookTierStandard, body: coreBody, }, { Name: "localization", Summary: "lean code-finding guidance — diet instructions body, proven tool surface", // ToolPreset is intentionally empty: the profile diets the // @-included body (the per-turn ambient) and keeps the // client-aware default tool surface. The deeper 14-tool // `localization` preset (built from EagerTools below) stays // available via GORTEX_TOOLS=localization — benchmarked, it // cut tools/list ~30% but cost file-hits on cap-adjacent // sessions, so it is opt-in rather than the profile default. EagerTools: localizationEagerTools, Skills: []string{"gortex-explore", "gortex-guide", "gortex-debug"}, HookTier: HookTierStandard, body: localizationBody, }, { Name: "full", Summary: "maximum guidance — the whole documented dev-cycle surface eager", ToolPreset: "core", HookTier: HookTierStandard, body: fullBody, }, } } // Names lists the profile names in table order. func Names() []string { t := Table() out := make([]string, len(t)) for i, p := range t { out[i] = p.Name } return out } // ByName resolves one profile row. func ByName(name string) (Profile, bool) { for _, p := range Table() { if p.Name == name { return p, true } } return Profile{}, false } // LocalizationEagerTools returns the eager allow-set of the // localization preset — the slice internal/mcp registers under the // preset name so the tool surface and the instructions body cannot // drift. func LocalizationEagerTools() []string { return append([]string(nil), localizationEagerTools...) } // Body renders the profile's instructions markdown. func (p Profile) Body() string { if p.body == nil { return "" } return p.body() } // DefaultDir is where profile files and the active state live: // /instructions (default ~/.gortex/instructions). func DefaultDir() string { return filepath.Join(platform.DataDir(), "instructions") } // ActiveFileName is the file agents @-include. It is always a byte // copy of the active profile's .md — never a symlink (Windows // parity, and @-include resolution should never depend on link // support). const ActiveFileName = "active.md" const stateFileName = "active.json" type state struct { Name string `json:"name"` SwitchedAt string `json:"switched_at,omitempty"` } // ActiveName reports which profile is active for dir: the ActiveEnv // override when it names a known profile, else the recorded state, // else DefaultName. Unknown or unreadable state degrades to // DefaultName — a missing file must behave exactly like a machine // that never switched. func ActiveName(dir string) string { if v := strings.TrimSpace(os.Getenv(ActiveEnv)); v != "" { if _, ok := ByName(v); ok { return v } } raw, err := os.ReadFile(filepath.Join(dir, stateFileName)) if err != nil { return DefaultName } var st state if json.Unmarshal(raw, &st) != nil { return DefaultName } if _, ok := ByName(st.Name); !ok { return DefaultName } return st.Name } // Active resolves the active profile row for dir. func Active(dir string) Profile { p, _ := ByName(ActiveName(dir)) return p } // ActiveToolPreset is the one-call convenience internal/mcp uses when // resolving a session's default tool surface. func ActiveToolPreset() string { return Active(DefaultDir()).ToolPreset } // ActiveHookTier is the one-call convenience internal/hooks uses. func ActiveHookTier() HookTier { return Active(DefaultDir()).HookTier } // Generate materialises every profile's .md under dir and // refreshes ActiveFileName to a byte copy of the active profile. // Idempotent: unchanged files are not rewritten (no spurious mtime // bumps on re-install). func Generate(dir string) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } for _, p := range Table() { if err := writeIfChanged(filepath.Join(dir, p.Name+".md"), []byte(p.Body())); err != nil { return fmt.Errorf("profile %s: %w", p.Name, err) } } return refreshActiveCopy(dir, Active(dir)) } // Switch makes name the active profile: regenerates the profile // files, atomically replaces ActiveFileName with a copy of // .md, and records the state. It does NOT touch skills or any // agent config — the cmd layer orchestrates those so this package // stays a leaf. The change applies to NEW sessions only: instructions // files, tools/list, and skills are all loaded at session start. func Switch(dir, name string) (Profile, error) { p, ok := ByName(name) if !ok { return Profile{}, fmt.Errorf("unknown instruction profile %q (known: %s)", name, strings.Join(Names(), ", ")) } if err := os.MkdirAll(dir, 0o755); err != nil { return Profile{}, err } for _, q := range Table() { if err := writeIfChanged(filepath.Join(dir, q.Name+".md"), []byte(q.Body())); err != nil { return Profile{}, fmt.Errorf("profile %s: %w", q.Name, err) } } if err := refreshActiveCopy(dir, p); err != nil { return Profile{}, err } st, err := json.Marshal(state{Name: name, SwitchedAt: time.Now().UTC().Format(time.RFC3339)}) if err != nil { return Profile{}, err } if err := atomicWrite(filepath.Join(dir, stateFileName), append(st, '\n')); err != nil { return Profile{}, err } return p, nil } // refreshActiveCopy atomically replaces active.md with p's body. func refreshActiveCopy(dir string, p Profile) error { return atomicWrite(filepath.Join(dir, ActiveFileName), []byte(p.Body())) } // writeIfChanged writes content to path unless it is already // byte-identical. func writeIfChanged(path string, content []byte) error { if existing, err := os.ReadFile(path); err == nil && string(existing) == string(content) { return nil } return atomicWrite(path, content) } // atomicWrite is temp-file + rename in path's directory. func atomicWrite(path string, content []byte) error { dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") if err != nil { return err } tmpName := tmp.Name() if _, err := tmp.Write(content); err != nil { tmp.Close() os.Remove(tmpName) return err } if err := tmp.Close(); err != nil { os.Remove(tmpName) return err } if err := os.Chmod(tmpName, 0o644); err != nil { os.Remove(tmpName) return err } if err := os.Rename(tmpName, path); err != nil { os.Remove(tmpName) return err } return nil } // Remove deletes the generated instructions directory. Used by // uninstall flows; missing dir is not an error. func Remove(dir string) error { err := os.RemoveAll(dir) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } return nil } // SortedEagerTools is a display helper for `gortex instructions list`. func (p Profile) SortedEagerTools() []string { out := append([]string(nil), p.EagerTools...) sort.Strings(out) return out }