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

274 lines
13 KiB
Go

// Instructions shared across every doc-aware adapter. Centralising the
// body here avoids per-adapter drift: Cursor's .cursor/rules file,
// Copilot's .github/copilot-instructions.md, Codex's AGENTS.md, and
// Claude Code's CLAUDE.md all read from the same constant, so when the
// "prefer Gortex over Read/Grep" story evolves we update it once and
// every agent sees the change on the next `gortex init`.
//
// The claudecode adapter extends this body with its own slash-commands
// appendix — that part is Claude-Code-specific and lives in
// claudecode/content.go, keyed off the same sentinel so idempotency
// checks line up across adapters.
package agents
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/profiles"
)
// InstructionsSentinel is the substring every doc-aware adapter checks
// for when deciding whether to append the instructions block. If it's
// already present (wherever it came from — a prior `gortex init`, a
// user-copied block, another adapter writing to a shared rules file
// like AGENTS.md) we skip to stay idempotent.
const InstructionsSentinel = "## MANDATORY: Use Gortex MCP tools"
// CommunitiesStartMarker / CommunitiesEndMarker fence the generated
// community-routing block that `gortex init` writes into per-repo
// instructions files. Fenced (not just start-only) because this block
// is regenerated on every `init` re-run as the codebase evolves, so
// we need to identify and overwrite it precisely without clobbering
// user edits around it.
const (
CommunitiesStartMarker = "<!-- gortex:communities:start -->"
CommunitiesEndMarker = "<!-- gortex:communities:end -->"
)
// GlobalRulesStartMarker / GlobalRulesEndMarker fence the rule block
// that `gortex install` merges into ~/.claude/CLAUDE.md. The block is
// idempotent (re-running install replaces it in place) and removable
// (user can delete the marked region by hand without other side
// effects). Distinct from the communities markers above because this
// block lives at user level and survives every project init.
const (
GlobalRulesStartMarker = "<!-- gortex:rules:start -->"
GlobalRulesEndMarker = "<!-- gortex:rules:end -->"
)
// GlobalPointerBody renders the thin machine-level rule block
// `gortex install` merges into ~/.claude/CLAUDE.md. The rule content
// itself lives in <instructionsDir>/active.md — an atomic byte copy of
// the selected instruction profile (internal/profiles) — and is pulled
// in through an @-include, so switching guidance depth never rewrites
// CLAUDE.md. The heading stays here as the idempotency sentinel and as
// a functional minimum for readers that do not expand @-includes.
func GlobalPointerBody(instructionsDir string) string {
active := filepath.Join(instructionsDir, profiles.ActiveFileName)
return "## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob\n\n" +
"The machine-wide Gortex rules load from the active instruction profile, imported below:\n\n" +
"@" + active + "\n\n" +
"Switch guidance depth with `gortex instructions switch <core|localization|full>` (`list` shows all) — applies to NEW sessions only.\n"
}
// InstructionsBody is the shared rule block every adapter writes to
// its agent's instructions file. Tool names in the tables (Read, Grep)
// are Claude-Code-specific flavour; models outside Claude Code read
// them as "any file-reading tool" — the principle stays the same so
// we keep one body rather than branch by agent.
const InstructionsBody = `## MANDATORY: Use Gortex MCP tools instead of Read/Grep
Gortex runs as an MCP server for this repository. You MUST prefer graph queries over file reads on every task — PreToolUse hooks deny ` + "`" + `Read` + "`" + ` / ` + "`" + `Grep` + "`" + ` / ` + "`" + `Glob` + "`" + ` against indexed source, and the deny message names the right tool.
**Start every task with ` + "`" + `explore` + "`" + `.** Describe the request in plain words (paste the whole issue — it is distilled server-side; you do not have to hand-craft a query) and it returns the ranked localization neighborhood — the likely-involved symbols with their source, call paths, and the files to change — in ONE call. Answer or start editing from its output; the source it returned is already in context, so do not re-open those files with ` + "`" + `Read` + "`" + ` / ` + "`" + `Glob` + "`" + ` — read more of a listed symbol with ` + "`" + `get_symbol_source` + "`" + ` / ` + "`" + `batch_symbols` + "`" + ` on its ` + "`" + `id:` + "`" + `.
| Instead of... | Use... |
|-------------------------------------|------------------------------------------|
| Localizing a task / bug / "where is X" | ` + "`" + `explore` + "`" + ` (one call: ranked neighborhood + source + call paths) |
| ` + "`" + `Grep` + "`" + ` for a symbol | ` + "`" + `search_symbols` + "`" + ` (BM25 + camelCase-aware) |
| ` + "`" + `Grep` + "`" + ` for references | ` + "`" + `find_usages` + "`" + ` (zero false positives) |
| Reading / grepping to find callers | ` + "`" + `get_callers` + "`" + ` / ` + "`" + `get_call_chain` + "`" + ` |
| ` + "`" + `Read` + "`" + ` a file for one symbol | ` + "`" + `get_symbol_source` + "`" + ` (` + "`" + `compress_bodies:true` + "`" + ` for the signature only) |
| ` + "`" + `Read` + "`" + ` to understand a file | ` + "`" + `get_file_summary` + "`" + ` / ` + "`" + `get_editing_context` + "`" + ` |
| ` + "`" + `Read` + "`" + ` a non-indexed / raw file | ` + "`" + `read_file` + "`" + ` |
| ` + "`" + `Read` + "`" + ` several symbols' bodies at once | ` + "`" + `batch_symbols` + "`" + ` (one call, many bodies) |
| ` + "`" + `Edit` + "`" + ` / ` + "`" + `Write` + "`" + ` source | ` + "`" + `edit_file` + "`" + ` / ` + "`" + `write_file` + "`" + ` / ` + "`" + `edit_symbol` + "`" + ` / ` + "`" + `rename_symbol` + "`" + ` / ` + "`" + `batch_edit` + "`" + ` |
**CLI fallback (no MCP):** every tool above is reachable from a shell as ` + "`" + `gortex call <tool> --arg k=v` + "`" + ` (e.g. ` + "`" + `gortex call read_file --arg path=<file>` + "`" + `) — there is no bare ` + "`" + `gortex <tool>` + "`" + ` verb.
The graph narrows scope; read the real body with ` + "`" + `get_symbol_source` + "`" + ` before you change or depend on a symbol — especially behavior-critical code (migrations, retry / fallback, concurrency), where ` + "`" + `compress_bodies:true` + "`" + ` would elide the risky branches. ` + "`" + `format:"gcx"` + "`" + ` and ` + "`" + `compress_bodies:true` + "`" + ` exist on the read / list tools — the parameter legend is in the MCP server instructions.
**Memory workflow** (behavior-critical; the full triggers live in the global ` + "`" + `~/.claude/CLAUDE.md` + "`" + ` policy and in ` + "`" + `gortex://guide` + "`" + `): ` + "`" + `distill_session` + "`" + ` at session start; ` + "`" + `surface_memories` + "`" + ` right after ` + "`" + `smart_context` + "`" + `; ` + "`" + `save_note tags:"decision"` + "`" + ` at each decision; ` + "`" + `store_memory` + "`" + ` for durable invariants / gotchas the team should inherit; ` + "`" + `query_notes` + "`" + ` / ` + "`" + `query_memories` + "`" + ` before re-touching a symbol.
**Reference:** ` + "`" + `gortex://guide` + "`" + ` (or ` + "`" + `gortex guide [topic]` + "`" + `) carries the full detail — provider matrix, capabilities, analyze / search_ast catalogs, token-economy, MCP resources. The server publishes a lean tool preset eagerly; call ` + "`" + `tools_search` + "`" + ` to discover and load any other tool by keyword.
`
// AppendInstructions appends body to path, creating the file if
// missing. Idempotent: when `sentinel` is already present anywhere in
// the file we skip with ActionSkip and log the reason. Callers pass
// the adapter's ApplyOpts through so --dry-run / --global / --force
// all flow to the right FileAction status.
//
// Not atomic. Rules files are plaintext a human edits, matching the
// historical CLAUDE.md append behaviour — a concurrent external writer
// during init is extraordinarily unlikely and atomic rename of a file
// a human is editing would fight their editor.
func AppendInstructions(w io.Writer, path, body, sentinel string, opts ApplyOpts) (FileAction, error) {
existing, readErr := os.ReadFile(path)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, readErr)
}
existed := readErr == nil
if existed && strings.Contains(string(existing), sentinel) {
if w != nil {
fmt.Fprintf(w, "[gortex init] skip %s (Gortex block already present)\n", path)
}
return FileAction{Path: path, Action: ActionSkip, Reason: "block-present"}, nil
}
if opts.DryRun {
action := ActionWouldMerge
if !existed {
action = ActionWouldCreate
}
return FileAction{Path: path, Action: action, Keys: []string{"gortex-block"}}, nil
}
// Two blank lines between existing content and the block so the
// appended section reads as a separate document and doesn't glue
// onto the last paragraph the user wrote.
prefix := ""
if existed && len(existing) > 0 {
prefix = "\n\n"
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return FileAction{}, err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return FileAction{}, err
}
defer f.Close()
if _, err := f.WriteString(prefix + body); err != nil {
return FileAction{}, err
}
if w != nil {
fmt.Fprintf(w, "[gortex init] appended Gortex block to %s\n", path)
}
action := ActionMerge
if !existed {
action = ActionCreate
}
return FileAction{Path: path, Action: action, Keys: []string{"gortex-block"}}, nil
}
// CursorMDCFrontmatter wraps the instructions body in the YAML
// frontmatter Cursor expects for MDC rules files. Cursor reads
// `alwaysApply: true` rules on every chat turn — which is what we
// want for the MANDATORY-prefer-Gortex block.
//
// Kept separate from AppendInstructions because MDC files are
// one-rule-per-file (Cursor owns the filename, not the content), so
// they use WriteIfNotExists semantics, not append.
func CursorMDCFrontmatter(body string) string {
return `---
description: Gortex code intelligence — prefer graph tools over file reads
alwaysApply: true
---
` + body
}
// UpsertMarkedBlock writes `body` into `path` between `startMarker`
// and `endMarker`. Unlike AppendInstructions, this is idempotent AND
// regeneratable: if the markers already exist the block between them
// is replaced; otherwise the block is appended with a blank-line gap
// to existing content. If `body` is empty and the markers exist, the
// block is removed (migration use case). Creates the file if missing.
//
// Designed for the per-repo community-routing block which regenerates
// on every `gortex init` run as the graph evolves.
func UpsertMarkedBlock(w io.Writer, path, body, startMarker, endMarker string, opts ApplyOpts) (FileAction, error) {
existing, readErr := os.ReadFile(path)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, readErr)
}
existed := readErr == nil
text := ""
if existed {
text = string(existing)
}
hasBlock := existed && strings.Contains(text, startMarker) && strings.Contains(text, endMarker)
empty := strings.TrimSpace(body) == ""
// Nothing to do: empty body and no existing block.
if empty && !hasBlock {
return FileAction{Path: path, Action: ActionSkip, Reason: "no-communities"}, nil
}
fenced := startMarker + "\n" + body + "\n" + endMarker + "\n"
var next string
switch {
case hasBlock:
start := strings.Index(text, startMarker)
end := strings.Index(text, endMarker) + len(endMarker)
// Trim trailing newline after the end marker so we don't
// accumulate blank lines on repeated re-runs.
if end < len(text) && text[end] == '\n' {
end++
}
if empty {
next = text[:start] + text[end:]
} else {
next = text[:start] + fenced + text[end:]
}
case !existed:
next = fenced
default:
prefix := ""
if len(text) > 0 {
if !strings.HasSuffix(text, "\n") {
prefix = "\n\n"
} else if !strings.HasSuffix(text, "\n\n") {
prefix = "\n"
}
}
next = text + prefix + fenced
}
// Skip when the file would end up byte-identical to what's
// already there — important for AssertIdempotent semantics and
// for avoiding spurious mtime bumps on `gortex init` re-runs
// when the graph hasn't changed.
if existed && next == text {
return FileAction{Path: path, Action: ActionSkip, Reason: "unchanged"}, nil
}
if opts.DryRun {
switch {
case !existed:
return FileAction{Path: path, Action: ActionWouldCreate, Keys: []string{"communities-block"}}, nil
case hasBlock:
return FileAction{Path: path, Action: ActionWouldMerge, Keys: []string{"communities-block"}}, nil
default:
return FileAction{Path: path, Action: ActionWouldMerge, Keys: []string{"communities-block"}}, nil
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return FileAction{}, err
}
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
return FileAction{}, err
}
if w != nil {
verb := "updated"
if !existed {
verb = "wrote"
}
fmt.Fprintf(w, "[gortex init] %s %s (communities block)\n", verb, path)
}
action := ActionMerge
if !existed {
action = ActionCreate
}
return FileAction{Path: path, Action: action, Keys: []string{"communities-block"}}, nil
}