Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c5aec01
fix(skills): stop double-fencing the community routing block
zzet Aug 15, 2026
ed3111c
refactor(agents): drop the dead sentinel-append instructions helper
zzet Aug 15, 2026
42689da
feat(audit): discover the instruction and skill trees Gortex now writes
zzet Aug 15, 2026
1a75bb3
feat(agents): add a GitHub Copilot CLI adapter
zzet Aug 15, 2026
bceefbd
refactor(agents): extract a host-neutral skill pack
zzet Aug 15, 2026
2b83945
docs: correct the adapter and skill counts and pin them to the registry
zzet Aug 15, 2026
201fb31
feat(hooks): add the GitHub Copilot CLI wire protocol
zzet Aug 15, 2026
38fe93c
feat(hooks): add an OpenCode bridge over a shared host-agnostic core
zzet Aug 15, 2026
98f1489
test(agents): widen the render fence to hooks, generated skills and u…
zzet Aug 15, 2026
411aa11
feat(agents): install the Gortex skills for Codex CLI
zzet Aug 15, 2026
d310ecb
feat(agents): give Copilot CLI the skills, subagents and hook config
zzet Aug 15, 2026
241e778
feat(agents): give OpenCode skills, commands and an enforcement bridge
zzet Aug 15, 2026
d20c692
docs(agents): document the new host surfaces and the ones that do not…
zzet Aug 15, 2026
9cc360c
ci: cover the Copilot CLI adapter in the init smoke test
zzet Aug 15, 2026
bc7c88f
feat(agents): make an instructions switch reshape every host's skills
zzet Aug 15, 2026
ad27d56
fix(agents): prune Hermes skills on a profile switch, and refuse an e…
zzet Aug 15, 2026
be2eb22
feat(doctor): report install state and hook activity for the new hosts
zzet Aug 15, 2026
04cac60
fix(uninstall): remove the artifacts the new hosts install
zzet Aug 15, 2026
a4dac00
fix(uninstall): remove Claude Code's per-community skills too
zzet Aug 15, 2026
227225d
test(agents): regenerate the render goldens for the widened fence
zzet Aug 15, 2026
5048657
style: gofmt the init wizard test
zzet Aug 15, 2026
8b177ae
feat(agents): install the Gortex sub-agents for Codex CLI
zzet Aug 15, 2026
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
10 changes: 8 additions & 2 deletions .github/workflows/init-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ jobs:
mkdir -p "$RUNNER_TEMP/repo/.omp"
# OpenClaw
mkdir -p "$RUNNER_TEMP/home/.openclaw"
# GitHub Copilot CLI — the standalone `copilot` binary, not the
# VS Code extension the `vscode` adapter targets.
mkdir -p "$RUNNER_TEMP/home/.copilot"

- name: Run gortex init --dry-run --json
id: run
Expand All @@ -88,9 +91,12 @@ jobs:
agents = {a["name"]: a for a in data["agents"]}
expected = [
"claude-code", "aider", "cline", "codex", "continue",
"cursor", "gemini", "kilocode", "kiro", "oh-my-pi", "opencode",
"openclaw", "vscode", "windsurf", "zed",
"copilot-cli", "cursor", "gemini", "kilocode", "kiro",
"oh-my-pi", "opencode", "openclaw", "vscode", "windsurf", "zed",
# "antigravity" always true when HOME is set; not a detection test.
# hermes, kimi and pi have no sentinel here yet — the list is
# hand-maintained, so a new adapter is only covered once it is
# added in both places above.
]
failed = []
for name in expected:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ High-quality parsing 257 languages/grammars through tree-sitter AST analysis, in
- **Cross-repo by default** — N repos in one graph; contracts, references, and call chains span repo boundaries with evidence-gated resolution, contract matching, impact analysis, per-session isolation → [docs/multi-repo.md](docs/multi-repo.md)
- **Extreamly fast analysis** — a precomputed depth-3 reach index turns blast-radius queries into O(seeds × reach) map lookups. Safe to ask "what breaks if I change this?" on every edit. No dozens of tool calls to grasp context.
- **Zero external dependencies** — single binary, everything in-process. No network, no model download to get started. Install, start daemon, use.
- **Agent integrations (19)** — `gortex init` configures every detected coding assistant on the machine → [docs/agents.md](docs/agents.md)
- **Agent integrations (20)** — `gortex init` configures every detected coding assistant on the machine → [docs/agents.md](docs/agents.md)
- **100+ MCP tools, 16 resources, 3 prompts** — symbol lookup, call chains, blast radius, dataflow, clone detection, refactoring, code actions → [docs/mcp.md](docs/mcp.md)
- **Semantic search default-on** — baked GloVe-50d (3.8 MB embedded), store-native FTS5/BM25 + vector with adaptive alpha fusion, zero deps; opt-in MiniLM / Ollama / OpenAI → [docs/semantic-search.md](docs/semantic-search.md)
- **Speculative execution** — `preview_edit` / `simulate_chain` answer "what would change if I applied this WorkspaceEdit?" without touching disk
Expand Down Expand Up @@ -162,7 +162,7 @@ Data flow, graph schema (node and edge kinds, multi-repo fields, test taxonomy),
| Optional LLM features | [llm.md](docs/llm.md) |
| LSP integration | [lsp.md](docs/lsp.md) |
| Per-community skills & agent usage | [skills.md](docs/skills.md) |
| AI agent adapters (19) | [agents.md](docs/agents.md) |
| AI agent adapters (20) | [agents.md](docs/agents.md) |
| Supported languages (257) | [languages.md](docs/languages.md) |
| Token savings | [savings.md](docs/savings.md) |
| GCX1 wire format | [wire-format.md](docs/wire-format.md) |
Expand Down
244 changes: 244 additions & 0 deletions cmd/gortex/agents_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import (
"path/filepath"
"strings"
"testing"
"time"

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

// updateAgentRender regenerates the committed agent-render goldens.
Expand Down Expand Up @@ -69,3 +71,245 @@ func TestAgentsRenderGolden(t *testing.T) {
}
}
}

// realHomeBeforeSandbox is the developer's actual home directory,
// captured during package-variable initialisation — which runs before
// TestMain installs the process-wide testenv sandbox that replaces
// $HOME. The render hermeticity guard needs the real path: after
// SandboxProcess, os.UserHomeDir() reports the sandbox and could not
// tell a genuine escape from a contained write.
var realHomeBeforeSandbox = func() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
}()

// neutraliseClaudeConfigDirOverride clears the process-wide Claude
// config-root seam for the duration of a test. The seam outranks
// $CLAUDE_CONFIG_DIR, so a value left behind by an earlier test (or by
// a future caller of `gortex install --claude-config-dir` in the same
// process) would send the ModeGlobal render somewhere the sandbox
// cannot see — the manifest's home/ section would silently render
// empty. The render itself cannot clear it: claudecode imports
// internal/agents, so internal/agents cannot import claudecode.
func neutraliseClaudeConfigDirOverride(t *testing.T) {
t.Helper()
claudecode.SetConfigDirOverride("")
t.Cleanup(func() { claudecode.SetConfigDirOverride("") })
}

// renderAllAdapters is the one-line render every guard below shares.
func renderAllAdapters(t *testing.T) map[string]string {
t.Helper()
manifests, err := agents.RenderManifest(buildRegistry().All())
if err != nil {
t.Fatalf("render adapters: %v", err)
}
return manifests
}

// manifestKeys extracts the `=== <key> ===` headers from a manifest.
func manifestKeys(manifest string) []string {
var keys []string
for _, line := range strings.Split(manifest, "\n") {
if strings.HasPrefix(line, "=== ") && strings.HasSuffix(line, " ===") {
keys = append(keys, strings.TrimSuffix(strings.TrimPrefix(line, "=== "), " ==="))
}
}
return keys
}

// TestAgentsRenderIsHermetic proves the drift fence cannot write
// outside its sandbox. It matters because the fence now renders
// ModeGlobal, whose whole job is writing user-level files: an escape
// would corrupt the developer's real home AND hide itself, because the
// bytes that left the sandbox are exactly the ones missing from the
// manifest's home/ section.
//
// The environment below is hostile on purpose. Every variable is one a
// developer may legitimately have exported, and each one used to be a
// live escape hatch or a determinism hole.
func TestAgentsRenderIsHermetic(t *testing.T) {
neutraliseClaudeConfigDirOverride(t)

decoy := t.TempDir()
claudeDecoy := filepath.Join(decoy, "claude-config-dir")
kimiDecoy := filepath.Join(decoy, "kimi-code-home")
t.Setenv("CLAUDE_CONFIG_DIR", claudeDecoy)
t.Setenv("KIMI_CODE_HOME", kimiDecoy)
t.Setenv("GORTEX_CODEX_HOOK_MODE", "suppress")
t.Setenv("GORTEX_INSTRUCTIONS_PROFILE", "full")

manifests := renderAllAdapters(t)

// 1. The decoy config roots must be untouched: if either had won,
// the adapter's user-level artifacts would have landed there.
for _, dir := range []string{claudeDecoy, kimiDecoy} {
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("render escaped the sandbox into %s (stat err: %v) — the config-dir env pin is not holding", dir, err)
}
}

// 2. No manifest may name the developer's real home. A leaked
// absolute path means something resolved a path outside the
// sandbox, whether or not it managed to write there.
if realHomeBeforeSandbox != "" {
for name, m := range manifests {
if strings.Contains(m, realHomeBeforeSandbox) {
t.Errorf("%s manifest references the real home %q", name, realHomeBeforeSandbox)
}
}
}

// 3. Nothing under the real home that the render *could* have
// written may have been created by this run. Every user-level
// manifest key is checked against its real-home counterpart, so
// the guard extends itself automatically as adapters grow new
// user-level surfaces.
if realHomeBeforeSandbox != "" {
checked := map[string]bool{}
for name, m := range manifests {
for _, key := range manifestKeys(m) {
rel, ok := homeRelativeManifestKey(key)
if !ok || checked[rel] {
continue
}
checked[rel] = true
path := filepath.Join(realHomeBeforeSandbox, filepath.FromSlash(rel))
info, err := os.Lstat(path)
if err != nil {
continue // absent is the expected state
}
if info.ModTime().After(renderGuardStart) {
t.Errorf("%s: %s was modified during the render — the sandbox leaked into the real home\n"+
"(false positive only if another process wrote that path during this test)", name, path)
}
}
}
}

// 4. The pinned variables are restored exactly as the test set them.
for k, want := range map[string]string{
"CLAUDE_CONFIG_DIR": claudeDecoy,
"KIMI_CODE_HOME": kimiDecoy,
"GORTEX_CODEX_HOOK_MODE": "suppress",
"GORTEX_INSTRUCTIONS_PROFILE": "full",
} {
if got := os.Getenv(k); got != want {
t.Errorf("%s = %q after the render, want it restored to %q", k, got, want)
}
}
}

// renderGuardStart is captured before any test runs, so the
// hermeticity guard can distinguish "this file was already here" from
// "this render just wrote it".
var renderGuardStart = time.Now().Add(-time.Second)

// homeRelativeManifestKey maps a `<mode>/home/<rel>` manifest key to
// <rel>, and reports false for repo-root keys (which have no real-home
// counterpart).
func homeRelativeManifestKey(key string) (string, bool) {
for _, prefix := range []string{"project/home/", "global/home/"} {
if strings.HasPrefix(key, prefix) {
return strings.TrimPrefix(key, prefix), true
}
}
return "", false
}

// TestAgentsRenderIsDeterministic pins the fence against the ambient
// environment. Both variables below are read at render time — codex
// bakes $GORTEX_CODEX_HOOK_MODE into its hook command, and
// profiles.ActiveName honours $GORTEX_INSTRUCTIONS_PROFILE, which
// selects the user-level skill set — so without the render's env pins
// the goldens would encode whichever posture the regenerating
// developer happened to export, and CI would then fail on a diff
// nobody can explain.
func TestAgentsRenderIsDeterministic(t *testing.T) {
neutraliseClaudeConfigDirOverride(t)

t.Setenv("GORTEX_CODEX_HOOK_MODE", "suppress")
t.Setenv("GORTEX_INSTRUCTIONS_PROFILE", "full")
first := renderAllAdapters(t)

t.Setenv("GORTEX_CODEX_HOOK_MODE", "rewrite")
t.Setenv("GORTEX_INSTRUCTIONS_PROFILE", "localization")
second := renderAllAdapters(t)

for name, got := range second {
if want, ok := first[name]; !ok {
t.Errorf("%s rendered only once across two runs", name)
} else if got != want {
t.Errorf("%s manifest depends on the ambient environment: it changed when "+
"$GORTEX_CODEX_HOOK_MODE / $GORTEX_INSTRUCTIONS_PROFILE changed", name)
}
}

// And the codex hook command must carry the shipped default posture,
// not the "suppress"/"rewrite" the environment asked for.
codex, ok := first["codex"]
if !ok {
t.Fatal("no codex manifest")
}
if !strings.Contains(codex, "--agent=codex --mode=enrich") {
t.Errorf("codex hook command does not carry the fixed default posture --mode=enrich")
}
for _, leaked := range []string{"--mode=suppress", "--mode=rewrite", "--mode=deny"} {
if strings.Contains(codex, leaked) {
t.Errorf("codex manifest carries %q from the ambient environment", leaked)
}
}
}

// TestAgentsRenderFenceCoverage is the positive counterpart to the
// goldens: it asserts the fence still *sees* the surfaces it was
// widened to cover. Without it, a change that quietly narrows the
// sandbox Env again — dropping InstallHooks, the generated-skill
// fixture, or the ModeGlobal pass — would sail through as a golden
// regeneration, which is exactly how the previous blind spot appeared.
func TestAgentsRenderFenceCoverage(t *testing.T) {
neutraliseClaudeConfigDirOverride(t)
manifests := renderAllAdapters(t)

claude, ok := manifests["claude-code"]
if !ok {
t.Fatal("no claude-code manifest")
}
for _, want := range []string{
// ModeGlobal user-level artifacts.
"=== global/home/.claude/skills/",
"=== global/home/.claude/CLAUDE.md ===",
// Lifecycle hooks, both scopes.
"=== project/root/.claude/settings.local.json ===",
"=== global/home/.claude/settings.local.json ===",
// Generated per-community skills.
"=== project/root/.claude/skills/generated/gortex-example-one/SKILL.md ===",
"=== project/root/.claude/skills/generated/gortex-example-two/SKILL.md ===",
} {
if !strings.Contains(claude, want) {
t.Errorf("claude-code manifest lost fence coverage for %q", want)
}
}

// Every adapter must render something in both modes; a mode that
// renders nothing at all is the signature of a narrowed fence.
var projectOnly []string
for name, m := range manifests {
if !strings.Contains(m, "=== project/") {
t.Errorf("%s rendered no project-mode artifacts", name)
}
if !strings.Contains(m, "=== global/") {
projectOnly = append(projectOnly, name)
}
}
// Some adapters legitimately have no user-level surface, so this is
// reported rather than failed — but every adapter that has one must
// keep it visible.
if len(projectOnly) == len(manifests) {
t.Error("no adapter rendered a global-mode artifact — the ModeGlobal pass is not reaching adapters")
}
t.Logf("adapters with no ModeGlobal artifacts: %v", projectOnly)
}
77 changes: 77 additions & 0 deletions cmd/gortex/docs_counts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package main

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

// docs_counts_test.go pins the adapter count printed in the docs to the
// registry itself.
//
// Three separate files advertise how many agent integrations ship, and
// all three had drifted — the docs still said nineteen while the
// registry had grown. A count nobody can verify is worse than no count:
// a reader who spots one stale number stops trusting the rest of the
// page. Deriving it here means the next adapter fails this test until
// its author updates every place that claims a total.

// countedDocs maps a repo-relative doc to the phrasing it uses for the
// adapter total. Each entry must contain the numeral, so a single
// substring check covers all three without parsing prose.
var countedDocs = []struct {
path string
context string // human hint printed on failure
}{
{"docs/agents.md", "\"NN adapters ship today\" in the intro"},
{"docs/skills.md", "\"NN other AI coding assistants\" under Usage with other agents"},
{"README.md", "\"Agent integrations (NN)\" and the docs table row"},
}

func TestDocsAdapterCountMatchesRegistry(t *testing.T) {
want := len(buildRegistry().All())
if want == 0 {
t.Fatal("buildRegistry() returned no adapters — the guard would pass vacuously")
}

root := repoRootForDocs(t)
for _, doc := range countedDocs {
t.Run(doc.path, func(t *testing.T) {
body, err := os.ReadFile(filepath.Join(root, doc.path))
if err != nil {
t.Fatalf("read %s: %v", doc.path, err)
}
// docs/skills.md counts the OTHER assistants — every adapter
// except claude-code, which the page describes separately.
n := want
if doc.path == "docs/skills.md" {
n = want - 1
}
if !strings.Contains(string(body), fmt.Sprint(n)) {
t.Errorf("%s does not mention the adapter count %d; update %s", doc.path, n, doc.context)
}
})
}
}

// repoRootForDocs walks up from the test's working directory (cmd/gortex)
// to the module root, so the guard works from any invocation directory.
func repoRootForDocs(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("no go.mod found above the test working directory")
}
dir = parent
}
}
Loading
Loading