Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/gortex/testdata/agent-render/claude-code.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
}
}
}
=== root/CLAUDE.md ===
=== root/AGENTS.md ===
<!-- gortex:communities:start -->
- [example-community](.claude/skills/example/SKILL.md) — example routing block

<!-- gortex:communities:end -->
=== root/CLAUDE.md ===
@AGENTS.md
17 changes: 10 additions & 7 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ machine. Nineteen adapters ship today.
`~/.gemini/antigravity/` Knowledge Items, and user-level hooks.
- `gortex init` writes per-repo machinery: `.mcp.json`, per-agent
MCP configs (`.cursor/mcp.json`, `.vscode/mcp.json`, …), repo-local
hooks where supported, per-agent marker-guarded community-routing
blocks, and `.claude/skills/generated/` per-community SKILL.md.
hooks where supported, marker-guarded community-routing surfaces
(`AGENTS.md` is canonical for Claude Code), and
`.claude/skills/generated/` per-community SKILL.md.

Run `gortex doctor` to see what's currently configured — and, past what a
config file can prove, whether the hooks it declares are actually running. Both
Expand All @@ -21,7 +22,7 @@ commands accept `--agents=<csv>` to constrain setup and

| Name | What gets written | Mode | Docs link |
| --------------- | ----------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------- |
| `claude-code` | `.mcp.json`, `.claude/*`, `CLAUDE.md`, `.claude/skills/generated/*`, `~/.claude/skills/gortex-*`, `~/.claude/commands/gortex-*.md`, `~/.claude.json` | both | https://docs.claude.com/en/docs/claude-code/overview |
| `claude-code` | `.mcp.json`, `.claude/*`, `AGENTS.md` communities block, `CLAUDE.md` import/overview, `.claude/skills/generated/*`, `~/.claude/skills/gortex-*`, `~/.claude/commands/gortex-*.md`, `~/.claude.json` | both | https://docs.claude.com/en/docs/claude-code/overview |
| `aider` | `.aiderignore` block, `CONVENTIONS.md` communities block | project | https://aider.chat/docs/config/aider_conf.html |
| `antigravity` | `~/.gemini/antigravity/mcp_config.json` + Knowledge Item | user | https://antigravity.google/docs/mcp |
| `cline` | `cline_mcp_settings.json` (per VS Code / Cursor globalStorage), `.clinerules/gortex-communities.md` | both | https://docs.cline.bot/mcp/mcp-overview |
Expand Down Expand Up @@ -194,10 +195,12 @@ The primary integration, split across the two commands.
(`mcp__gortex__*` allowlist)
- `.claude/settings.local.json` — repo-local hooks (unless
`--no-hooks`)
- `CLAUDE.md` — marker-guarded block (`<!-- gortex:communities:start -->`
/ `<!-- gortex:communities:end -->`) carrying the codebase overview
(via `--analyze`) and the community routing (via `--skills`,
default on); if neither flag produces content, no block is written
- `AGENTS.md` — canonical marker-guarded community-routing block
(`<!-- gortex:communities:start -->` /
`<!-- gortex:communities:end -->`) when `--skills` is enabled
- `CLAUDE.md` — imports `@AGENTS.md` exactly once when community routing
is generated; its marker-guarded block carries only the Claude-specific
codebase overview from `--analyze`
- `.claude/skills/generated/<DirName>/SKILL.md` — one per detected
community, regenerated each run so the content tracks the graph

Expand Down
4 changes: 2 additions & 2 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ After `gortex install` (once per machine) and `gortex init` (once per repo), Cla
- **PreToolUse hook:** automatic graph context + graph-tool suggestions on Read/Grep/Glob. The posture is selectable via `gortex install --hook-mode` — `deny` (default), `enrich`, `consult-unlock` (deny fallback reads only until the graph has been queried once this session), or `nudge` (a rate-limited soft reminder instead of a hard deny). Gortex's own MCP tools are auto-approved under the host's permissive permission modes
- **PreCompact hook:** condensed orientation snapshot injected before context compaction so the agent resumes without re-exploring
- **Stop hook:** post-task diagnostics — tests to run, guard violations, dead code, and contract issues on the changed symbols — injected as context before the agent hands off
- **CLAUDE.md:** per-repo codebase overview (via `--analyze`) plus a marker-guarded community routing block written by `gortex init --skills`
- **AGENTS.md + CLAUDE.md:** `gortex init --skills` writes the marker-guarded community routing once to canonical `AGENTS.md`; Claude Code consumes it through one `@AGENTS.md` import in `CLAUDE.md`, whose managed block remains reserved for the per-repo `--analyze` overview

## The `gortex-cli` skill — a zero-schema consumption path

Expand Down Expand Up @@ -60,4 +60,4 @@ Each generated skill includes:
- **Cross-community connections** — which other areas this community interacts with
- **MCP tool invocations** — pre-written `get_communities`, `smart_context`, `find_usages` calls

For Claude Code, skills are written to `.claude/skills/generated/<DirName>/SKILL.md`, and a routing table is inserted into `CLAUDE.md` between `<!-- gortex:communities:start/end -->` markers. Every other detected agent gets the same routing table inside its per-repo instructions surface (`AGENTS.md` for Codex/OpenCode, `.windsurfrules` for Windsurf, `GEMINI.md` for Gemini CLI, `.cursor/rules/gortex-communities.mdc` for Cursor, etc.) — so the routing is consistent across tools on the same repo.
For Claude Code, skills are written to `.claude/skills/generated/<DirName>/SKILL.md`, while the routing table is inserted once into canonical `AGENTS.md` between `<!-- gortex:communities:start/end -->` markers. `CLAUDE.md` imports that file with `@AGENTS.md`. Every other detected agent gets the same routing table inside its per-repo instructions surface (`AGENTS.md` for Codex/OpenCode, `.windsurfrules` for Windsurf, `GEMINI.md` for Gemini CLI, `.cursor/rules/gortex-communities.mdc` for Cursor, etc.) — so the routing is consistent across tools on the same repo without duplicating the Claude copy.
105 changes: 82 additions & 23 deletions internal/agents/claudecode/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,18 @@ func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {

// Project mode — only genuinely repo-specific artifacts. No
// tool-usage duplication: that lives at ~/.claude/skills/
// (installed by `gortex install`). CLAUDE.md gets a
// marker-guarded block only when --analyze or --skills produce
// codebase-derived content.
// (installed by `gortex install`). Community routing is canonical
// in AGENTS.md; CLAUDE.md imports it and retains only --analyze
// content in its marker-guarded block.
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".mcp.json"), Action: agents.ActionWouldCreate, Keys: []string{"mcpServers"}})
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "settings.json"), Action: agents.ActionWouldMerge, Keys: []string{"permissions"}})
if env.InstallHooks {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "settings.local.json"), Action: agents.ActionWouldMerge, Keys: []string{"hooks"}})
}
if env.AnalyzedOverview != "" || env.SkillsRouting != "" {
if env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, "AGENTS.md"), Action: agents.ActionWouldMerge, Keys: []string{"communities-block"}})
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, "CLAUDE.md"), Action: agents.ActionWouldMerge, Keys: []string{"agents-import", "communities-block"}})
} else if env.AnalyzedOverview != "" {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, "CLAUDE.md"), Action: agents.ActionWouldMerge, Keys: []string{"communities-block"}})
}
for _, s := range env.GeneratedSkills {
Expand Down Expand Up @@ -154,32 +157,35 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result,
logf(w, "[gortex init] skipping hook installation (--no-hooks)")
}

// 4. CLAUDE.md — only written when there's genuinely
// codebase-specific content to place there: either the
// --analyze overview, the --skills community routing, or both.
// Generic tool-usage moved to user-level ~/.claude/skills/
// (installed by `gortex install`).
// 4. Community routing is canonical in AGENTS.md so every agent can
// share one generated table. Claude Code imports that canonical file
// from CLAUDE.md; an existing Gortex-managed CLAUDE.md block is
// migrated away while any --analyze overview remains Claude-specific.
if env.SkillsRouting != "" {
agentsMdPath := filepath.Join(env.Root, "AGENTS.md")
routingAction, err := agents.UpsertMarkedBlock(w, agentsMdPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, fmt.Errorf("AGENTS.md: %w", err)
}
res.Files = append(res.Files, routingAction)
}
if env.AnalyzedOverview != "" || env.SkillsRouting != "" {
claudeMdPath := filepath.Join(env.Root, "CLAUDE.md")
var body strings.Builder
if env.AnalyzedOverview != "" {
body.WriteString(env.AnalyzedOverview)
if !strings.HasSuffix(env.AnalyzedOverview, "\n") {
body.WriteString("\n")
}
}
if env.SkillsRouting != "" {
if body.Len() > 0 {
body.WriteString("\n")
}
body.WriteString(env.SkillsRouting)
}
claudeAction, err := agents.UpsertMarkedBlock(w, claudeMdPath, body.String(),
claudeAction, err := agents.UpsertMarkedBlock(w, claudeMdPath, env.AnalyzedOverview,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, fmt.Errorf("CLAUDE.md: %w", err)
}
res.Files = append(res.Files, claudeAction)

if env.SkillsRouting != "" {
importAction, err := ensureProjectAgentsImport(w, claudeMdPath, opts)
if err != nil {
return res, fmt.Errorf("CLAUDE.md import: %w", err)
}
res.Files = append(res.Files, importAction)
}
}

// 5. Generated community skills — per-community SKILL.md files
Expand Down Expand Up @@ -558,6 +564,59 @@ func pathExists(path string) bool {
return err == nil
}

const projectAgentsImport = "@AGENTS.md"

// ensureProjectAgentsImport adds Claude Code's project-level import for the
// canonical shared instructions without rewriting or duplicating user content.
func ensureProjectAgentsImport(w io.Writer, path string, opts agents.ApplyOpts) (agents.FileAction, error) {
action := agents.FileAction{Path: path, Keys: []string{"agents-import"}}
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return action, err
}
if hasProjectAgentsImport(string(existing)) {
action.Action = agents.ActionSkip
action.Reason = "unchanged"
return action, nil
}

newline := "\n"
if strings.Contains(string(existing), "\r\n") {
newline = "\r\n"
}
content := projectAgentsImport + newline
if len(existing) > 0 {
content += newline + string(existing)
}
if opts.DryRun {
if os.IsNotExist(err) {
action.Action = agents.ActionWouldCreate
} else {
action.Action = agents.ActionWouldMerge
}
return action, nil
}
if err := agents.AtomicWriteFile(path, []byte(content), 0o644); err != nil {
return action, err
}
if os.IsNotExist(err) {
action.Action = agents.ActionCreate
} else {
action.Action = agents.ActionMerge
}
logf(w, "[gortex init] ensured %s imports AGENTS.md", path)
return action, nil
}

func hasProjectAgentsImport(content string) bool {
for _, line := range strings.Split(content, "\n") {
if strings.TrimSpace(line) == projectAgentsImport {
return true
}
}
return false
}

// installPermissions merges an {"permissions": {"allow":
// ["mcp__gortex__*"]}} stanza into settings.json. Preserves any
// user-added entries; short-circuits when a gortex rule is already
Expand Down
20 changes: 14 additions & 6 deletions internal/agents/claudecode/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ func TestInstallPermissionsMigratesWildcardButPreservesCustomPolicy(t *testing.T
// - .mcp.json with our server stanza
// - .claude/settings.json with MCP permissions
// - .claude/settings.local.json with the lifecycle and tool hook events
// - CLAUDE.md with the marker-guarded communities block (since
// the test env seeds SkillsRouting)
// - AGENTS.md with the marker-guarded communities block
// - CLAUDE.md importing the canonical AGENTS.md instructions
// - .claude/skills/generated/<DirName>/SKILL.md (one per
// GeneratedSkill)
//
Expand All @@ -81,6 +81,7 @@ func TestClaudeCodeProjectModeCreatesCanonicalArtifacts(t *testing.T) {
filepath.Join(env.Root, ".mcp.json"),
filepath.Join(env.Root, ".claude", "settings.json"),
filepath.Join(env.Root, ".claude", "settings.local.json"),
filepath.Join(env.Root, "AGENTS.md"),
filepath.Join(env.Root, "CLAUDE.md"),
}
for _, s := range env.GeneratedSkills {
Expand Down Expand Up @@ -116,11 +117,18 @@ func TestClaudeCodeProjectModeCreatesCanonicalArtifacts(t *testing.T) {
}
}

// CLAUDE.md must contain the communities-block markers (since
// the stub SkillsRouting routes through UpsertMarkedBlock).
// Community routing has one canonical copy in AGENTS.md. CLAUDE.md
// imports it rather than carrying a second generated block.
agentsMd, _ := os.ReadFile(filepath.Join(env.Root, "AGENTS.md"))
if !strings.Contains(string(agentsMd), agents.CommunitiesStartMarker) {
t.Fatalf("AGENTS.md missing communities start marker: %s", agentsMd)
}
claudeMd, _ := os.ReadFile(filepath.Join(env.Root, "CLAUDE.md"))
if !strings.Contains(string(claudeMd), agents.CommunitiesStartMarker) {
t.Fatalf("CLAUDE.md missing communities start marker: %s", claudeMd)
if strings.Count(string(claudeMd), projectAgentsImport) != 1 {
t.Fatalf("CLAUDE.md must import AGENTS.md exactly once: %s", claudeMd)
}
if strings.Contains(string(claudeMd), agents.CommunitiesStartMarker) {
t.Fatalf("CLAUDE.md unexpectedly duplicates communities block: %s", claudeMd)
}

// Hooks file must reference our test hook command.
Expand Down
131 changes: 131 additions & 0 deletions internal/agents/claudecode/community_routing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package claudecode

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)

func TestProjectCommunityRoutingUsesCanonicalAgentsFile(t *testing.T) {
env, _ := agentstest.NewEnv(t)
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}

agentsBody := readProjectInstructions(t, filepath.Join(env.Root, "AGENTS.md"))
for _, want := range []string{agents.CommunitiesStartMarker, agentstest.StubSkillsRouting, agents.CommunitiesEndMarker} {
if !strings.Contains(agentsBody, want) {
t.Errorf("AGENTS.md missing %q:\n%s", want, agentsBody)
}
}
claudeBody := readProjectInstructions(t, filepath.Join(env.Root, "CLAUDE.md"))
if claudeBody != "@AGENTS.md\n" {
t.Fatalf("CLAUDE.md = %q, want only canonical import", claudeBody)
}
}

func TestProjectCommunityRoutingMigratesManagedClaudeBlockAndPreservesUserContent(t *testing.T) {
env, _ := agentstest.NewEnv(t)
claudePath := filepath.Join(env.Root, "CLAUDE.md")
agentsPath := filepath.Join(env.Root, "AGENTS.md")
oldClaude := "# User preface\n\n" + agents.CommunitiesStartMarker + "\nold generated routing\n" + agents.CommunitiesEndMarker + "\n\n## Claude Code\nKeep this section.\n"
if err := os.WriteFile(claudePath, []byte(oldClaude), 0o644); err != nil {
t.Fatalf("seed CLAUDE.md: %v", err)
}
if err := os.WriteFile(agentsPath, []byte("# Team rules\nKeep these rules.\n"), 0o644); err != nil {
t.Fatalf("seed AGENTS.md: %v", err)
}

a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}

claudeBody := readProjectInstructions(t, claudePath)
for _, want := range []string{"@AGENTS.md", "# User preface", "## Claude Code", "Keep this section."} {
if !strings.Contains(claudeBody, want) {
t.Errorf("CLAUDE.md lost %q:\n%s", want, claudeBody)
}
}
for _, unwanted := range []string{agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, "old generated routing", agentstest.StubSkillsRouting} {
if strings.Contains(claudeBody, unwanted) {
t.Errorf("CLAUDE.md retained %q:\n%s", unwanted, claudeBody)
}
}
if got := strings.Count(claudeBody, "@AGENTS.md"); got != 1 {
t.Fatalf("CLAUDE.md import count = %d, want 1:\n%s", got, claudeBody)
}

agentsBody := readProjectInstructions(t, agentsPath)
for _, want := range []string{"# Team rules", "Keep these rules.", agents.CommunitiesStartMarker, agentstest.StubSkillsRouting} {
if !strings.Contains(agentsBody, want) {
t.Errorf("AGENTS.md lost %q:\n%s", want, agentsBody)
}
}

res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second apply: %v", err)
}
for _, action := range res.Files {
if action.Action != agents.ActionSkip {
t.Errorf("second apply action for %s = %s, want skip", action.Path, action.Action)
}
}
}

func TestProjectCommunityRoutingDoesNotDuplicateExistingImport(t *testing.T) {
env, _ := agentstest.NewEnv(t)
claudePath := filepath.Join(env.Root, "CLAUDE.md")
original := "@AGENTS.md\n\n## Claude Code\nClaude-only policy.\n"
if err := os.WriteFile(claudePath, []byte(original), 0o644); err != nil {
t.Fatalf("seed CLAUDE.md: %v", err)
}
if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
body := readProjectInstructions(t, claudePath)
if got := strings.Count(body, "@AGENTS.md"); got != 1 {
t.Fatalf("CLAUDE.md import count = %d, want 1:\n%s", got, body)
}
if !strings.Contains(body, "Claude-only policy.") {
t.Fatalf("CLAUDE.md lost Claude-specific content:\n%s", body)
}
}

func TestProjectAnalyzeOverviewStaysInClaudeWhileRoutingMovesToAgents(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.AnalyzedOverview = "## Repository Overview\nClaude-specific overview."
if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
claudeBody := readProjectInstructions(t, filepath.Join(env.Root, "CLAUDE.md"))
for _, want := range []string{"@AGENTS.md", agents.CommunitiesStartMarker, env.AnalyzedOverview, agents.CommunitiesEndMarker} {
if !strings.Contains(claudeBody, want) {
t.Errorf("CLAUDE.md missing %q:\n%s", want, claudeBody)
}
}
if strings.Contains(claudeBody, agentstest.StubSkillsRouting) {
t.Fatalf("CLAUDE.md duplicated community routing:\n%s", claudeBody)
}
if agentsBody := readProjectInstructions(t, filepath.Join(env.Root, "AGENTS.md")); !strings.Contains(agentsBody, agentstest.StubSkillsRouting) {
t.Fatalf("AGENTS.md missing community routing:\n%s", agentsBody)
}
}

func readProjectInstructions(t *testing.T, path string) string {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", filepath.Base(path), err)
}
return string(body)
}
2 changes: 1 addition & 1 deletion internal/skills/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (g *Generator) GenerateAll() []GeneratedSkill {
return skills
}

// GenerateRouting produces the CLAUDE.md routing table between markers.
// GenerateRouting produces the shared agent routing table between markers.
func (g *Generator) GenerateRouting(skills []GeneratedSkill) string {
var sb strings.Builder
sb.WriteString("<!-- gortex:skills:start -->\n")
Expand Down
Loading