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

334 lines
11 KiB
Go

// Package profiles defines the instruction profiles a machine can run
// Gortex under: named bundles of (a) an instructions body written to
// ~/.gortex/instructions/<name>.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:
// <gortex data dir>/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 <name>.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 <name>.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
// <name>.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
}