From 36ba931775b05841171993961b9f2180faa64375 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Wed, 9 Sep 2026 15:41:01 -0400 Subject: [PATCH 1/8] GH-18: add central store support to resolver --- projects/resolver.go | 118 ++++++++++++++++++++--- projects/resolver_test.go | 198 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 11 deletions(-) diff --git a/projects/resolver.go b/projects/resolver.go index c76acc7..34de279 100644 --- a/projects/resolver.go +++ b/projects/resolver.go @@ -50,13 +50,16 @@ type Resolver struct { roots []string explicit []string global string // path to global knowledge store (empty if none) + store string // central store dir (empty if disabled) } -// Build constructs a resolver from org roots, explicit project paths, and an -// optional global knowledge path. It returns startup warnings for ambiguous -// names and path overlaps. It returns an error for unresolvable configurations: -// nonexistent or non-directory paths, empty values, and duplicate root basenames. -func Build(roots, projects []string, global string) (*Resolver, []string, error) { +// BuildWithStore constructs a resolver from org roots, explicit project +// paths, an optional global knowledge path, and an optional central store +// directory. When store is non-empty, project and org knowledge dirs are +// rooted under it (see AgentsDir/OrgKnowledgeDir), in-tree .agents/ dirs +// are ignored with warnings, and discovered refs inside the store dir are +// excluded from discovery. +func BuildWithStore(roots, projects []string, global, store string) (*Resolver, []string, error) { var warnings []string rootPaths, err := canonicalAll(roots, "root") @@ -71,6 +74,15 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error) rootPaths = dedupePaths(rootPaths, &warnings, "root") projPaths = dedupePaths(projPaths, &warnings, "project") + storePath := "" + if store != "" { + stored, err := canonicalAll([]string{store}, "store") + if err != nil { + return nil, nil, err + } + storePath = stored[0] + } + // Root basenames must be unique: they qualify project names. seenRoots := map[string]string{} for _, rp := range rootPaths { @@ -96,6 +108,10 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error) var refs []Ref for _, pp := range projPaths { + if storePath != "" && pathWithin(pp, storePath) { + warnings = append(warnings, fmt.Sprintf( + "explicit project %s is inside the --store directory", pp)) + } refs = append(refs, Ref{ Name: filepath.Base(pp), Qualifier: filepath.Base(filepath.Dir(pp)), @@ -109,13 +125,24 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error) return nil, nil, fmt.Errorf("scan root %q: %w", rp, err) } warnings = append(warnings, childWarnings...) - refs = append(refs, children...) - if hasOrgKnowledge(rp) { + for _, child := range children { + if storePath != "" && pathWithin(child.Path, storePath) { + warnings = append(warnings, fmt.Sprintf( + "skipping %s under root %s: inside the --store directory", + child.Name, rp)) + continue + } + refs = append(refs, child) + } + if hasOrgKnowledge(rp, storePath) { refs = append(refs, Ref{ Name: filepath.Base(rp), Path: rp, Kind: KindOrg, }) + } else if storePath != "" && hasOrgKnowledge(rp, "") { + warnings = append(warnings, fmt.Sprintf( + "ignoring in-tree org knowledge at %s (--store is set)", rp)) } } @@ -152,6 +179,7 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error) roots: rootPaths, explicit: projPaths, global: global, + store: storePath, } // Dedupe by path; project kind wins over org kind. @@ -201,6 +229,7 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error) ref.Address = ref.Qualifier + "/" + ref.Name } r.byBare[ref.Name] = append(r.byBare[ref.Name], *ref) + r.byPath[ref.Path] = *ref } // Address collisions should be impossible after the checks above; @@ -212,6 +241,25 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error) r.byAddress[ref.Address] = ref } + if storePath != "" { + for _, ref := range r.refs { + switch ref.Kind { + case KindProject: + info, err := os.Stat(filepath.Join(ref.Path, ".agents")) + if err == nil && info.IsDir() { + warnings = append(warnings, fmt.Sprintf( + "ignoring in-tree .agents at %s (--store is set)", ref.Path)) + } + case KindOrg: + info, err := os.Stat(filepath.Join(ref.Path, ".agents", "knowledge")) + if err == nil && info.IsDir() { + warnings = append(warnings, fmt.Sprintf( + "ignoring in-tree org knowledge at %s (--store is set)", ref.Path)) + } + } + } + } + // Warn about ambiguous bare names. for name, refs := range r.byBare { if len(refs) > 1 && !hasOrgRef(refs) { @@ -224,6 +272,12 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error) return r, warnings, nil } +// Build constructs a resolver without a central store (in-tree .agents/ +// stores, the original behavior). +func Build(roots, projects []string, global string) (*Resolver, []string, error) { + return BuildWithStore(roots, projects, global, "") +} + // Resolve maps a bare or qualified name to a Ref. Unknown names and // ambiguous bare names return errors listing known names or candidates. func (r *Resolver) Resolve(name string) (Ref, error) { @@ -321,10 +375,48 @@ func (r *Resolver) Covers(path string) bool { return false } +// underStore reports whether a path is at or under r.store. +func (r *Resolver) underStore(path string) bool { + if r.store == "" { + return false + } + return pathWithin(path, r.store) +} + +// AgentsDir returns the knowledge directory for a ref: its in-tree +// .agents/ when no store is configured, or its slot in the central store. +// The global store is never re-rooted. +func (r *Resolver) AgentsDir(ref Ref) string { + if r.store != "" && ref.Kind != KindGlobal { + return filepath.Join(r.store, ref.Address, ".agents") + } + return filepath.Join(ref.Path, ".agents") +} + +// OrgKnowledgeDir returns the org-level knowledge directory for an org ref. +func (r *Resolver) OrgKnowledgeDir(ref Ref) string { + return filepath.Join(r.AgentsDir(ref), "knowledge") +} + +// RefForPath returns the ref registered for an exact canonical path. +func (r *Resolver) RefForPath(path string) (Ref, bool) { + resolved, ok := canonicalize(path) + if !ok { + return Ref{}, false + } + ref, ok := r.byPath[resolved] + return ref, ok +} + +// pathWithin reports whether path equals dir or lies under it. +func pathWithin(path, dir string) bool { + return path == dir || strings.HasPrefix(path, dir+string(filepath.Separator)) +} + func (r *Resolver) resolveDynamicBare(name string) (Ref, bool) { for _, root := range r.roots { path := filepath.Join(root, name) - if info, err := os.Stat(path); err == nil && info.IsDir() { + if info, err := os.Stat(path); err == nil && info.IsDir() && !r.underStore(path) { return Ref{ Name: name, Qualifier: filepath.Base(root), Path: path, Kind: KindProject, Address: name, @@ -342,7 +434,7 @@ func (r *Resolver) resolveDynamicQualified(name string) (Ref, bool) { continue } path := filepath.Join(root, base) - if info, err := os.Stat(path); err == nil && info.IsDir() { + if info, err := os.Stat(path); err == nil && info.IsDir() && !r.underStore(path) { return Ref{ Name: base, Qualifier: qualifier, Path: path, Kind: KindProject, Address: name, @@ -387,8 +479,12 @@ func ScanRoot(root string) ([]Ref, []string, error) { return refs, warnings, nil } -func hasOrgKnowledge(root string) bool { - info, err := os.Stat(filepath.Join(root, ".agents", "knowledge")) +func hasOrgKnowledge(root, store string) bool { + knowledgePath := filepath.Join(root, ".agents", "knowledge") + if store != "" { + knowledgePath = filepath.Join(store, filepath.Base(root), ".agents", "knowledge") + } + info, err := os.Stat(knowledgePath) return err == nil && info.IsDir() } diff --git a/projects/resolver_test.go b/projects/resolver_test.go index 40d0988..40158d5 100644 --- a/projects/resolver_test.go +++ b/projects/resolver_test.go @@ -378,3 +378,201 @@ func TestGlobalRefNone(t *testing.T) { t.Error("GlobalRef() should return false when no global is configured") } } + +func TestBuildWithStoreInvalid(t *testing.T) { + tmp := t.TempDir() + + if _, _, err := BuildWithStore(nil, nil, "", filepath.Join(tmp, "missing")); err == nil { + t.Error("nonexistent store path should error") + } + file := filepath.Join(tmp, "afile") + os.WriteFile(file, []byte("x"), 0644) + if _, _, err := BuildWithStore(nil, nil, "", file); err == nil { + t.Error("file store path should error") + } +} + +func TestAgentsDirWithoutStore(t *testing.T) { + dir := mkdir(t, t.TempDir(), "myproj") + + res, _, err := Build(nil, []string{dir}, "") + if err != nil { + t.Fatalf("Build() error: %v", err) + } + ref, err := res.Resolve("myproj") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if want := filepath.Join(dir, ".agents"); res.AgentsDir(ref) != want { + t.Errorf("AgentsDir = %q, want %q", res.AgentsDir(ref), want) + } + if want := filepath.Join(dir, ".agents", "knowledge"); res.OrgKnowledgeDir(ref) != want { + t.Errorf("OrgKnowledgeDir = %q, want %q", res.OrgKnowledgeDir(ref), want) + } +} + +func TestAgentsDirWithStore(t *testing.T) { + r1 := mkdir(t, t.TempDir(), "r1") + r2 := mkdir(t, t.TempDir(), "r2") + mkdir(t, r1, "api") + mkdir(t, r2, "api") + globalDir := mkdir(t, t.TempDir(), "global") + store := mkdir(t, t.TempDir(), "store") + + res, _, err := BuildWithStore([]string{r1, r2}, nil, globalDir, store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + qualified, err := res.Resolve("r1/api") + if err != nil { + t.Fatalf("Resolve(r1/api) error: %v", err) + } + if want := filepath.Join(store, "r1", "api", ".agents"); res.AgentsDir(qualified) != want { + t.Errorf("AgentsDir(qualified) = %q, want %q", res.AgentsDir(qualified), want) + } + + gref, err := res.Resolve("_global") + if err != nil { + t.Fatalf("Resolve(_global) error: %v", err) + } + if want := filepath.Join(globalDir, ".agents"); res.AgentsDir(gref) != want { + t.Errorf("AgentsDir(global) = %q, want %q (must never re-root)", res.AgentsDir(gref), want) + } +} + +func TestBuildWithStoreExcludesStoreUnderRoot(t *testing.T) { + root := t.TempDir() + mkdir(t, root, "alpha") + store := mkdir(t, root, ".knowledge") + mkdir(t, store, "alpha") // store child mirrors a project name + + res, warnings, err := BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + if _, err := res.Resolve(".knowledge"); err == nil { + t.Error("store dir itself must not be discovered as a project") + } + warned := false + for _, w := range warnings { + if strings.Contains(w, "inside the --store directory") { + warned = true + } + } + if !warned { + t.Errorf("expected exclusion warning, got %v", warnings) + } + + // Dynamic resolution must not resurrect the store dir either. + if _, err := res.Resolve(".knowledge"); err == nil { + t.Error("dynamic resolution must exclude the store dir") + } +} + +func TestBuildWithStoreInTreeWarnings(t *testing.T) { + root := t.TempDir() + proj := mkdir(t, root, "proj") + mkdir(t, proj, ".agents") + org := mkdir(t, t.TempDir(), "org") + mkdir(t, org, ".agents", "knowledge") + store := mkdir(t, t.TempDir(), "store") + + _, warnings, err := BuildWithStore([]string{root, org}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + var projWarn, orgWarn int + for _, w := range warnings { + if strings.Contains(w, "ignoring in-tree .agents at") { + projWarn++ + } + if strings.Contains(w, "ignoring in-tree org knowledge at") { + orgWarn++ + } + } + if projWarn != 1 { + t.Errorf("want 1 in-tree project warning, got %d (%v)", projWarn, warnings) + } + if orgWarn != 1 { + t.Errorf("want 1 in-tree org warning, got %d (%v)", orgWarn, warnings) + } +} + +func TestBuildWithStoreOrgKnowledgeInStore(t *testing.T) { + root := mkdir(t, t.TempDir(), "org") + store := mkdir(t, t.TempDir(), "store") + mkdir(t, store, "org", ".agents", "knowledge") + + res, _, err := BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + ref, err := res.Resolve("org") + if err != nil { + t.Fatalf("Resolve(org) error: %v", err) + } + if ref.Kind != KindOrg { + t.Errorf("store-hosted org knowledge should create an org ref, got kind %v", ref.Kind) + } + if want := filepath.Join(store, "org", ".agents", "knowledge"); res.OrgKnowledgeDir(ref) != want { + t.Errorf("OrgKnowledgeDir = %q, want %q", res.OrgKnowledgeDir(ref), want) + } +} + +func TestBuildWithStoreNoOrgKnowledgeInTree(t *testing.T) { + root := mkdir(t, t.TempDir(), "org") + mkdir(t, root, ".agents", "knowledge") // in-tree only + store := mkdir(t, t.TempDir(), "store") + + res, _, err := BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + if _, err := res.Resolve("org"); err == nil { + t.Error("in-tree org knowledge must not create an org ref when --store is set") + } +} + +func TestRefForPath(t *testing.T) { + dir := mkdir(t, t.TempDir(), "myproj") + + res, _, err := Build(nil, []string{dir}, "") + if err != nil { + t.Fatalf("Build() error: %v", err) + } + + ref, ok := res.RefForPath(dir) + if !ok || ref.Address != "myproj" { + t.Errorf("RefForPath(%q) = %#v, %v; want myproj", dir, ref, ok) + } + if _, ok := res.RefForPath(filepath.Join(dir, "nonexistent")); ok { + t.Error("RefForPath should return false for unknown paths") + } +} + +func TestExplicitProjectInsideStoreKept(t *testing.T) { + store := mkdir(t, t.TempDir(), "store") + inner := mkdir(t, store, "inner") + + res, warnings, err := BuildWithStore(nil, []string{inner}, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + if _, err := res.Resolve("inner"); err != nil { + t.Errorf("explicit project inside store should stay resolvable: %v", err) + } + found := false + for _, w := range warnings { + if strings.Contains(w, "explicit project") { + found = true + } + } + if !found { + t.Errorf("expected explicit-project warning, got %v", warnings) + } +} From c2e7a8dd6d80dc2d7910f0f1dd4d5bd4c2c8d4f3 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Wed, 9 Sep 2026 15:45:32 -0400 Subject: [PATCH 2/8] GH-18: default index into store dir when --store is set --- main.go | 13 ++++++++----- main_test.go | 44 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index aef2ec9..7236387 100644 --- a/main.go +++ b/main.go @@ -67,7 +67,7 @@ func main() { log.Printf("warning: %s", w) } - indexBasePath, err := indexLocation(*indexOverride, roots, projs, resolver.Entries()) + indexBasePath, err := indexLocation(*indexOverride, "", roots, projs, resolver.Entries()) if err != nil { log.Fatalf("determine index location: %v", err) } @@ -325,13 +325,16 @@ func splitSections(data string) [][2]string { return sections } -// indexLocation picks the bleve index path: explicit override, legacy -// per-config locations for single-entry configs, or a hashed XDG state -// dir for multi-entry configs. -func indexLocation(override string, roots, projs pathList, canonicalEntries []string) (string, error) { +// indexLocation picks the bleve index path: explicit override, a --store +// default (/.index), legacy per-config locations for single-entry +// configs, or a hashed XDG state dir for multi-entry configs. +func indexLocation(override, store string, roots, projs pathList, canonicalEntries []string) (string, error) { if override != "" { return filepath.Abs(override) } + if store != "" { + return filepath.Join(store, ".index"), nil + } if len(roots)+len(projs) == 1 { single := roots if len(single) == 0 { diff --git a/main_test.go b/main_test.go index 3dc11c1..d1a54ad 100644 --- a/main_test.go +++ b/main_test.go @@ -57,7 +57,7 @@ func TestPathListSet(t *testing.T) { } func TestIndexLocationOverride(t *testing.T) { - p, err := indexLocation("/custom/index", pathList{}, pathList{}, nil) + p, err := indexLocation("/custom/index", "", pathList{}, pathList{}, nil) if err != nil { t.Fatalf("indexLocation() error: %v", err) } @@ -67,7 +67,7 @@ func TestIndexLocationOverride(t *testing.T) { } func TestIndexLocationLegacySingleProject(t *testing.T) { - p, err := indexLocation("", pathList{}, pathList{"/proj"}, nil) + p, err := indexLocation("", "", pathList{}, pathList{"/proj"}, nil) if err != nil { t.Fatalf("indexLocation() error: %v", err) } @@ -77,7 +77,7 @@ func TestIndexLocationLegacySingleProject(t *testing.T) { } func TestIndexLocationLegacySingleRoot(t *testing.T) { - p, err := indexLocation("", pathList{"/root"}, pathList{}, nil) + p, err := indexLocation("", "", pathList{"/root"}, pathList{}, nil) if err != nil { t.Fatalf("indexLocation() error: %v", err) } @@ -90,7 +90,7 @@ func TestIndexLocationMultiEntryStateDir(t *testing.T) { t.Setenv("XDG_STATE_HOME", t.TempDir()) entries := []string{"/rootB", "/rootA", "/projC"} - p, err := indexLocation("", pathList{"/rootB", "/rootA"}, pathList{"/projC"}, entries) + p, err := indexLocation("", "", pathList{"/rootB", "/rootA"}, pathList{"/projC"}, entries) if err != nil { t.Fatalf("indexLocation() error: %v", err) } @@ -99,7 +99,7 @@ func TestIndexLocationMultiEntryStateDir(t *testing.T) { } // Same entries -> same location (deterministic). - p2, err := indexLocation("", pathList{"/rootA", "/rootB"}, pathList{"/projC"}, entries) + p2, err := indexLocation("", "", pathList{"/rootA", "/rootB"}, pathList{"/projC"}, entries) if err != nil { t.Fatalf("indexLocation() error: %v", err) } @@ -109,7 +109,7 @@ func TestIndexLocationMultiEntryStateDir(t *testing.T) { // Different entries -> different location. entries2 := []string{"/rootB", "/rootA", "/projD"} - p3, err := indexLocation("", pathList{"/rootB", "/rootA"}, pathList{"/projD"}, entries2) + p3, err := indexLocation("", "", pathList{"/rootB", "/rootA"}, pathList{"/projD"}, entries2) if err != nil { t.Fatalf("indexLocation() error: %v", err) } @@ -124,7 +124,7 @@ func TestIndexLocationXDGDefault(t *testing.T) { if err != nil { t.Skip("no home dir") } - p, err := indexLocation("", pathList{"/a", "/b"}, pathList{}, []string{"/a", "/b"}) + p, err := indexLocation("", "", pathList{"/a", "/b"}, pathList{}, []string{"/a", "/b"}) if err != nil { t.Fatalf("indexLocation() error: %v", err) } @@ -132,3 +132,33 @@ func TestIndexLocationXDGDefault(t *testing.T) { t.Errorf("location = %q, want under %q", p, want) } } + +func TestIndexLocationStoreDefault(t *testing.T) { + p, err := indexLocation("", "/store", pathList{"/root"}, pathList{}, nil) + if err != nil { + t.Fatalf("indexLocation() error: %v", err) + } + if want := filepath.Join("/store", ".index"); p != want { + t.Errorf("location = %q, want %q", p, want) + } +} + +func TestIndexLocationStoreMultiEntry(t *testing.T) { + p, err := indexLocation("", "/store", pathList{"/a", "/b"}, pathList{"/c"}, []string{"/a", "/b", "/c"}) + if err != nil { + t.Fatalf("indexLocation() error: %v", err) + } + if want := filepath.Join("/store", ".index"); p != want { + t.Errorf("location = %q, want %q", p, want) + } +} + +func TestIndexLocationOverrideBeatsStore(t *testing.T) { + p, err := indexLocation("/custom/index", "/store", pathList{}, pathList{}, nil) + if err != nil { + t.Fatalf("indexLocation() error: %v", err) + } + if p != "/custom/index" { + t.Errorf("location = %q, want %q", p, "/custom/index") + } +} From 46ae15ed6cfc149840996a500bd585ff3f3cef61 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Wed, 9 Sep 2026 15:50:44 -0400 Subject: [PATCH 3/8] GH-18: route tool handlers through resolver agents dir --- tools/list.go | 9 ++-- tools/tools_test.go | 117 ++++++++++++++++++++++++++++++++++++++++++++ tools/update.go | 8 ++- tools/write.go | 11 +++-- 4 files changed, 129 insertions(+), 16 deletions(-) diff --git a/tools/list.go b/tools/list.go index b1f6948..da4a2fb 100644 --- a/tools/list.go +++ b/tools/list.go @@ -3,7 +3,6 @@ package tools import ( "context" "fmt" - "path/filepath" "strings" "github.com/mark3labs/mcp-go/mcp" @@ -37,7 +36,7 @@ func ListHandler(res *projects.Resolver, idx *search.Index) func(context.Context // the project's own. if ref.Address != projects.GlobalAddress { if globalRef, ok := res.GlobalRef(); ok { - output += listProjectEntries(globalRef.Path, projects.GlobalAddress, filterCategory, " (global)") + output += listProjectEntries(res.AgentsDir(globalRef), projects.GlobalAddress, filterCategory, " (global)") } } @@ -49,8 +48,7 @@ func ListHandler(res *projects.Resolver, idx *search.Index) func(context.Context return mcp.NewToolResultText(output), nil } - projectPath := ref.Path - local := listProjectEntries(projectPath, project, filterCategory, "") + local := listProjectEntries(res.AgentsDir(ref), project, filterCategory, "") if local == "" { if strings.TrimSpace(output) == "" { @@ -108,8 +106,7 @@ func listOrgEntries(idx *search.Index, orgAddress, filterCategory string) string return output } -func listProjectEntries(projectPath, projectName, filterCategory, headerSuffix string) string { - agentsDir := filepath.Join(projectPath, ".agents") +func listProjectEntries(agentsDir, projectName, filterCategory, headerSuffix string) string { var constraints []knowledge.Entry type catEntries struct { diff --git a/tools/tools_test.go b/tools/tools_test.go index 22c5ff7..04c6e11 100644 --- a/tools/tools_test.go +++ b/tools/tools_test.go @@ -1181,3 +1181,120 @@ func TestListConstraintsSeparation(t *testing.T) { t.Errorf("regular entry should appear in regular entries section: %s", content[regularIdx:]) } } + +func TestWriteToCentralStore(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + knowledge.EnsureDir(proj) + store := filepath.Join(t.TempDir(), "store") + knowledge.EnsureDir(store) + + resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + handler := WriteHandler(resolver, nil) + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "project": "proj", + "category": "conventions", + "summary": "central", + "detail": "stored centrally", + "source": "test", + } + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("write error: %v", err) + } + if result.IsError { + t.Fatalf("write returned error: %v", extractTextContent(t, result)) + } + + want := filepath.Join(store, "proj", ".agents", "conventions.yaml") + if !knowledge.FileExists(want) { + t.Errorf("store file missing: %s", want) + } + if knowledge.FileExists(filepath.Join(proj, ".agents", "conventions.yaml")) { + t.Error("in-tree .agents must not be created under --store") + } +} + +func TestListIgnoresInTreeUnderStore(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + knowledge.EnsureDir(filepath.Join(proj, ".agents")) + kf := &knowledge.KnowledgeFile{ + Project: "proj", Version: 1, + Entries: []knowledge.Entry{ + {ID: "conv-001", Summary: "stale in-tree entry", Source: "test", Date: knowledge.Today()}, + }, + } + if err := knowledge.Save(knowledge.CategoryFilePath(filepath.Join(proj, ".agents"), "conventions"), kf); err != nil { + t.Fatalf("seed in-tree: %v", err) + } + + store := filepath.Join(t.TempDir(), "store") + knowledge.EnsureDir(store) + resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + handler := ListHandler(resolver, nil) + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"project": "proj"} + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("list error: %v", err) + } + content := extractTextContent(t, result) + if strings.Contains(content, "stale in-tree entry") { + t.Errorf("list must not surface in-tree entries under --store: %s", content) + } +} + +func TestUpdateInCentralStore(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + knowledge.EnsureDir(proj) + store := filepath.Join(t.TempDir(), "store") + knowledge.EnsureDir(store) + + resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + want := filepath.Join(store, "proj", ".agents", "conventions.yaml") + writeHandler := WriteHandler(resolver, nil) + writeReq := mcp.CallToolRequest{} + writeReq.Params.Arguments = map[string]interface{}{ + "project": "proj", "category": "conventions", + "summary": "before", "detail": "before detail", "source": "test", + } + if _, err := writeHandler(context.Background(), writeReq); err != nil { + t.Fatalf("write error: %v", err) + } + + handler := UpdateHandler(resolver, nil) + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "project": "proj", "category": "conventions", "id": "conv-001", "summary": "after", + } + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("update error: %v", err) + } + if result.IsError { + t.Fatalf("update returned error: %v", extractTextContent(t, result)) + } + + loaded, loadErr := knowledge.Load(want) + if loadErr != nil { + t.Fatalf("load store file: %v", loadErr) + } + if loaded.Entries[0].Summary != "after" { + t.Errorf("summary = %q, want %q", loaded.Entries[0].Summary, "after") + } +} diff --git a/tools/update.go b/tools/update.go index e65fd1e..1a04607 100644 --- a/tools/update.go +++ b/tools/update.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "path/filepath" "github.com/mark3labs/mcp-go/mcp" "github.com/renderorange/knowledge-mcp/knowledge" @@ -39,12 +38,11 @@ func UpdateHandler(res *projects.Resolver, idx *search.Index) func(context.Conte } if ref.Kind == projects.KindOrg { return mcp.NewToolResultError(fmt.Sprintf( - "%q is an org root; org-level knowledge is file-based — edit %s/.agents/knowledge/ directly", - project, ref.Path)), nil + "%q is an org root; org-level knowledge is file-based — edit %s directly", + project, res.OrgKnowledgeDir(ref))), nil } - projectPath := ref.Path - agentsDir := filepath.Join(projectPath, ".agents") + agentsDir := res.AgentsDir(ref) catPath := knowledge.CategoryFilePath(agentsDir, category) mu := fileLocks.Get(catPath) diff --git a/tools/write.go b/tools/write.go index c7e6175..695bbb8 100644 --- a/tools/write.go +++ b/tools/write.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "path/filepath" "github.com/mark3labs/mcp-go/mcp" "github.com/renderorange/knowledge-mcp/knowledge" @@ -64,12 +63,14 @@ func WriteHandler(res *projects.Resolver, idx *search.Index) func(context.Contex } if ref.Kind == projects.KindOrg { return mcp.NewToolResultError(fmt.Sprintf( - "%q is an org root; org-level knowledge is file-based — edit %s/.agents/knowledge/ directly", - project, ref.Path)), nil + "%q is an org root; org-level knowledge is file-based — edit %s directly", + project, res.OrgKnowledgeDir(ref))), nil } - projectPath := ref.Path - agentsDir := filepath.Join(projectPath, ".agents") + agentsDir := res.AgentsDir(ref) + if err := knowledge.EnsureDir(agentsDir); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("create agents directory: %v", err)), nil + } catPath := knowledge.CategoryFilePath(agentsDir, category) mu := fileLocks.Get(catPath) From 0d6eb81c762e2c724da3f0470bb06bd191b4eab5 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Wed, 9 Sep 2026 15:55:15 -0400 Subject: [PATCH 4/8] GH-18: target central store in init_knowledge under --store --- projects/resolver.go | 5 ++++ tools/init.go | 29 ++++++++++++++++---- tools/tools_test.go | 65 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/projects/resolver.go b/projects/resolver.go index 34de279..5919290 100644 --- a/projects/resolver.go +++ b/projects/resolver.go @@ -383,6 +383,11 @@ func (r *Resolver) underStore(path string) bool { return pathWithin(path, r.store) } +// StoreEnabled reports whether a central store is configured. +func (r *Resolver) StoreEnabled() bool { + return r.store != "" +} + // AgentsDir returns the knowledge directory for a ref: its in-tree // .agents/ when no store is configured, or its slot in the central store. // The global store is never re-rooted. diff --git a/tools/init.go b/tools/init.go index 0e10f64..2604ddd 100644 --- a/tools/init.go +++ b/tools/init.go @@ -26,6 +26,25 @@ func InitHandler(res *projects.Resolver) func(context.Context, mcp.CallToolReque } agentsDir := filepath.Join(projectPath, ".agents") + resolvable := res != nil && res.Covers(projectPath) + + if res != nil && res.StoreEnabled() { + ref, ok := res.RefForPath(projectPath) + if !ok && res.Covers(projectPath) { + if resolved, resolveErr := res.Resolve(filepath.Base(projectPath)); resolveErr == nil { + ref, ok = resolved, true + } + } + if !ok { + return mcp.NewToolResultError( + "init_knowledge targets the central store under --store; add this project via --project or --root and restart"), nil + } + if ref.Kind == projects.KindOrg { + return mcp.NewToolResultError("org roots have no per-project store"), nil + } + agentsDir = res.AgentsDir(ref) + resolvable = true + } if err := knowledge.EnsureDir(agentsDir); err != nil { return mcp.NewToolResultError(fmt.Sprintf("create directory: %v", err)), nil @@ -69,19 +88,17 @@ func InitHandler(res *projects.Resolver) func(context.Context, mcp.CallToolReque } } - resolvable := res != nil && res.Covers(projectPath) - if len(created) == 0 { if resolvable { - return mcp.NewToolResultText("already initialized — .agents/ exists with all files"), nil + return mcp.NewToolResultText(fmt.Sprintf("already initialized — %s exists with all files", agentsDir)), nil } - return mcp.NewToolResultText("already initialized — .agents/ exists with all files\nwarning: this path is not under any configured root/project; add it via --project or --root and restart to make it queryable"), nil + return mcp.NewToolResultText(fmt.Sprintf("already initialized — %s exists with all files\nwarning: this path is not under any configured root/project; add it via --project or --root and restart to make it queryable", agentsDir)), nil } if resolvable { - return mcp.NewToolResultText(fmt.Sprintf("initialized .agents/ with: %v", created)), nil + return mcp.NewToolResultText(fmt.Sprintf("initialized %s with: %v", agentsDir, created)), nil } return mcp.NewToolResultText(fmt.Sprintf( - "initialized .agents/ with: %v\nwarning: this path is not under any configured root/project; add it via --project or --root and restart to make it queryable", created)), nil + "initialized %s with: %v\nwarning: this path is not under any configured root/project; add it via --project or --root and restart to make it queryable", agentsDir, created)), nil } } diff --git a/tools/tools_test.go b/tools/tools_test.go index 04c6e11..f908d13 100644 --- a/tools/tools_test.go +++ b/tools/tools_test.go @@ -1298,3 +1298,68 @@ func TestUpdateInCentralStore(t *testing.T) { t.Errorf("summary = %q, want %q", loaded.Entries[0].Summary, "after") } } + +func TestInitTargetsCentralStore(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + knowledge.EnsureDir(proj) + store := filepath.Join(t.TempDir(), "store") + knowledge.EnsureDir(store) + + resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"project_path": proj} + result, err := InitHandler(resolver)(context.Background(), req) + if err != nil { + t.Fatalf("init error: %v", err) + } + if result.IsError { + t.Fatalf("init returned error: %v", extractTextContent(t, result)) + } + + want := filepath.Join(store, "proj", ".agents") + if !knowledge.FileExists(knowledge.CategoryFilePath(want, "conventions")) { + t.Errorf("store not initialized at %s", want) + } + if knowledge.FileExists(filepath.Join(proj, ".agents", "conventions.yaml")) { + t.Error("init must not create in-tree .agents under --store") + } + content := extractTextContent(t, result) + if !strings.Contains(content, want) { + t.Errorf("result should report the store path %q, got: %s", want, content) + } +} + +func TestInitUnresolvableUnderStore(t *testing.T) { + root := t.TempDir() + other := filepath.Join(t.TempDir(), "elsewhere") + knowledge.EnsureDir(other) + store := filepath.Join(t.TempDir(), "store") + knowledge.EnsureDir(store) + + resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"project_path": other} + result, err := InitHandler(resolver)(context.Background(), req) + if err != nil { + t.Fatalf("init error: %v", err) + } + if !result.IsError { + t.Fatal("unresolvable init under --store must return an error") + } + content := extractTextContent(t, result) + if !strings.Contains(content, "--store") { + t.Errorf("error should explain --store, got: %s", content) + } + if knowledge.FileExists(filepath.Join(other, ".agents")) { + t.Error("unresolvable init must not write in-tree .agents under --store") + } +} From 0f3e93e6f13c4f38e773178c176a052998631593 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Wed, 9 Sep 2026 15:58:28 -0400 Subject: [PATCH 5/8] GH-18: wire --store flag through server startup --- main.go | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/main.go b/main.go index 7236387..feb9e7f 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ func main() { showVersion := flag.Bool("version", false, "Print version and exit") globalPath := flag.String("global", "", "Path to a global knowledge store shared across all projects") indexOverride := flag.String("index", "", "Override the search index location") + storeDir := flag.String("store", "", "Central directory for all knowledge stores; in-tree .agents/ is ignored when set") flag.Var(&roots, "root", "Org root whose immediate children are projects (repeatable)") flag.Var(&projs, "project", "Single project root (repeatable)") flag.Parse() @@ -58,7 +59,7 @@ func main() { os.Exit(1) } - resolver, warnings, err := projects.Build([]string(roots), []string(projs), *globalPath) + resolver, warnings, err := projects.BuildWithStore([]string(roots), []string(projs), *globalPath, *storeDir) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) @@ -67,7 +68,7 @@ func main() { log.Printf("warning: %s", w) } - indexBasePath, err := indexLocation(*indexOverride, "", roots, projs, resolver.Entries()) + indexBasePath, err := indexLocation(*indexOverride, *storeDir, roots, projs, resolver.Entries()) if err != nil { log.Fatalf("determine index location: %v", err) } @@ -226,19 +227,18 @@ func indexAll(res *projects.Resolver, idx *search.Index) { for _, ref := range res.Snapshot() { switch ref.Kind { case projects.KindProject: - indexProjectKnowledge(ref.Path, ref.Address, idx) + indexProjectKnowledge(res.AgentsDir(ref), ref.Address, idx) case projects.KindOrg: - indexOrgKnowledge(ref.Path, ref.Name, idx) + indexOrgKnowledge(res.OrgKnowledgeDir(ref), ref.Name, idx) case projects.KindGlobal: - indexProjectKnowledge(ref.Path, ref.Address, idx) + indexProjectKnowledge(res.AgentsDir(ref), ref.Address, idx) } } } -// indexProjectKnowledge indexes all knowledge files in a single project +// indexProjectKnowledge indexes all knowledge files under an agents dir // under the given addressing name. -func indexProjectKnowledge(projectPath, projectName string, idx *search.Index) { - agentsDir := filepath.Join(projectPath, ".agents") +func indexProjectKnowledge(agentsDir, projectName string, idx *search.Index) { for _, cat := range knowledge.ValidCategories() { catPath := knowledge.CategoryFilePath(agentsDir, cat) kf, err := knowledge.Load(catPath) @@ -260,13 +260,12 @@ func indexProjectKnowledge(projectPath, projectName string, idx *search.Index) { } } -// indexOrgKnowledge indexes an org root's .agents/knowledge/ files as -// individual markdown sections, so queries return only the relevant -// section instead of the entire document. -func indexOrgKnowledge(root, orgName string, idx *search.Index) { - orgAgentsDir := filepath.Join(root, ".agents", "knowledge") +// indexOrgKnowledge indexes an org's knowledge files as individual markdown +// sections, so queries return only the relevant section instead of the +// entire document. +func indexOrgKnowledge(knowledgeDir, orgName string, idx *search.Index) { for _, catFile := range []string{"architecture.md", "review.md"} { - filePath := filepath.Join(orgAgentsDir, catFile) + filePath := filepath.Join(knowledgeDir, catFile) data, err := os.ReadFile(filePath) if err != nil { continue From d59f8b643b3e31020f43cc25bce8efe9aa08bfd7 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Wed, 9 Sep 2026 16:00:53 -0400 Subject: [PATCH 6/8] GH-18: add store-mode end-to-end integration test --- tools/integration_test.go | 101 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tools/integration_test.go b/tools/integration_test.go index cc8d481..9e2315c 100644 --- a/tools/integration_test.go +++ b/tools/integration_test.go @@ -354,6 +354,107 @@ func TestFullWorkflow(t *testing.T) { } } +func TestStoreModeEndToEnd(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + os.MkdirAll(proj, 0755) + + // Seed an in-tree store that must be ignored under --store. + agentsDir := filepath.Join(proj, ".agents") + os.MkdirAll(agentsDir, 0755) + inTree := &knowledge.KnowledgeFile{ + Project: "proj", Version: 1, + Entries: []knowledge.Entry{ + {ID: "conv-001", Summary: "stale in-tree entry", Source: "test", Date: knowledge.Today()}, + }, + } + if err := knowledge.Save(knowledge.CategoryFilePath(agentsDir, "conventions"), inTree); err != nil { + t.Fatalf("seed in-tree store: %v", err) + } + + store := t.TempDir() + resolver, warnings, err := projects.BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + warned := false + for _, w := range warnings { + if strings.Contains(w, "ignoring in-tree .agents") { + warned = true + } + } + if !warned { + t.Errorf("expected in-tree ignore warning, got %v", warnings) + } + + idx, err := search.NewIndex(filepath.Join(store, ".index"), []string{"proj"}) + if err != nil { + t.Fatalf("NewIndex() error: %v", err) + } + defer idx.Close() + + // Init writes into the store. + initReq := mcp.CallToolRequest{} + initReq.Params.Arguments = map[string]interface{}{"project_path": proj} + initResult, err := InitHandler(resolver)(context.Background(), initReq) + if err != nil { + t.Fatalf("init error: %v", err) + } + if initResult.IsError { + t.Fatalf("init returned error: %v", extractTextContent(t, initResult)) + } + if !knowledge.FileExists(filepath.Join(store, "proj", ".agents", "conventions.yaml")) { + t.Fatal("init did not create the central store") + } + + // Write goes to the store. + writeHandler := WriteHandler(resolver, idx) + writeReq := mcp.CallToolRequest{} + writeReq.Params.Arguments = map[string]interface{}{ + "project": "proj", + "category": "conventions", + "summary": "fresh central entry", + "detail": "centralized reverb parameters", + "source": "test", + } + if _, err := writeHandler(context.Background(), writeReq); err != nil { + t.Fatalf("write error: %v", err) + } + + // Query hits the store entry, not the in-tree one. + queryHandler := QueryHandler(resolver, idx) + queryReq := mcp.CallToolRequest{} + queryReq.Params.Arguments = map[string]interface{}{"project": "proj", "query": "reverb"} + queryResult, err := queryHandler(context.Background(), queryReq) + if err != nil { + t.Fatalf("query error: %v", err) + } + queryContent := extractTextContent(t, queryResult) + if !strings.Contains(queryContent, "fresh central entry") { + t.Errorf("query should find the central entry, got: %s", queryContent) + } + if strings.Contains(queryContent, "stale in-tree entry") { + t.Error("query must not find in-tree entries under --store") + } + + // List shows the store entry only. + listHandler := ListHandler(resolver, idx) + listReq := mcp.CallToolRequest{} + listReq.Params.Arguments = map[string]interface{}{"project": "proj"} + listResult, err := listHandler(context.Background(), listReq) + if err != nil { + t.Fatalf("list error: %v", err) + } + listContent := extractTextContent(t, listResult) + if !strings.Contains(listContent, "fresh central entry") { + t.Errorf("list should show the central entry, got: %s", listContent) + } + if strings.Contains(listContent, "stale in-tree entry") { + t.Error("list must not show in-tree entries under --store") + } +} + // extractTextContent extracts the text content from an MCP tool result. func extractTextContent(t *testing.T, result *mcp.CallToolResult) string { t.Helper() From a08eeefef2679edf72c1a82dca41abe8c483d4ed Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Wed, 9 Sep 2026 16:03:31 -0400 Subject: [PATCH 7/8] GH-18: document --store central store mode --- README.md | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5a766b4..e1b4eaa 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,40 @@ Repeat `--root` and `--project` as needed; they can be mixed: } ``` +### Central store + +`--store ` re-roots every knowledge store under one central directory, +keeping repos clean of `.agents/`: + +```jsonc +// opencode.jsonc +{ + "mcp": { + "knowledge-mcp": { + "command": ["knowledge-mcp", "--root", "/path/to/org", "--store", "/path/to/knowledge"], + "type": "local" + } + } +} +``` + +Layout: + +``` +/ + .index/ # bleve index (default; --index wins) + /.agents/ # per-project store (qualified names nest: + # / -> ///.agents) + /.agents/knowledge/ # org-level markdown +``` + +- In-tree `.agents/` dirs are ignored (startup warnings list them). Migrate + by moving them: `mkdir -p /
&& mv /.agents /
/.agents`. +- Only `--global` stays where it is; it merges into listings as usual. +- `init_knowledge` initializes the central store for resolvable paths and + errors for others (add them via `--project`/`--root` first). +- The store directory itself must exist and is never created for you. + ### Shared global store `--global ` adds one knowledge store shared across every project. Querying or listing any project automatically merges the global store's entries alongside the project's own. @@ -102,14 +136,14 @@ General notes: - Each `--root` discovers its immediate children as projects (one level deep — pass deeper directories as additional flags). - Duplicate project basenames across roots are addressed as `/` (e.g. `work/api`); `list_projects` shows which names need qualification. - `query_knowledge` accepts org root names to search that root's `.agents/knowledge/` files. Org-level documents are indexed **per `##` section**, so a query returns the matching section(s), not the entire file. -- Multi-entry configurations store the search index under `$XDG_STATE_HOME/knowledge-mcp/` (default `~/.local/state/knowledge-mcp/`). Single-flag configurations keep the index inside their own `.agents/`. +- Multi-entry configurations store the search index under `$XDG_STATE_HOME/knowledge-mcp/` (default `~/.local/state/knowledge-mcp/`). Single-flag configurations keep the index inside their own `.agents/` — unless `--store` is set, in which case the index lives at `/.index`. - `--index ` overrides the index location in all modes. ## MCP Tools | Tool | Description | |------|-------------| -| `init_knowledge` | Create `.agents/` directory structure for a project | +| `init_knowledge` | Create a project's knowledge directory structure (central store under `--store`) | | `write_knowledge` | Add a new knowledge entry | | `query_knowledge` | Full-text search with category filters | | `list_knowledge` | List all entries; entries with rules shown first as constraints | From 4fd2edaf678d8b2ea8af5056c661c6c56165d756 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Thu, 10 Sep 2026 09:48:15 -0400 Subject: [PATCH 8/8] GH-18: harden --store resolver against slot collisions Fix five findings from the adversarial GH-18 review: - init_knowledge: verify the fallback ref's canonical path before binding, so a same-named sibling in another root or an org ref cannot hijack the write into the wrong store slot (tools/init.go, projects.CanonicalPath) - resolver: skip .index children in store mode at discovery and in dynamic resolution so a project slot can never live inside the removable index directory (projects/resolver.go) - resolver: suppress the in-tree ignore warning when the store slot IS the in-tree dir for explicit projects or org roots placed inside the store - tests: store-mode fallback binding, .index skip, slot warning suppression, and _global write/update/list under --store regression guard - README: document the central store's single-writer constraint --- README.md | 2 + projects/resolver.go | 42 ++++++++- projects/resolver_test.go | 71 +++++++++++++++ tools/init.go | 7 +- tools/tools_test.go | 178 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 294 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e1b4eaa..30590c0 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ Layout: - `init_knowledge` initializes the central store for resolvable paths and errors for others (add them via `--project`/`--root` first). - The store directory itself must exist and is never created for you. +- The store is single-writer: its search index and files do not support + concurrent access, so point only one server at a store. ### Shared global store diff --git a/projects/resolver.go b/projects/resolver.go index 5919290..964b1d3 100644 --- a/projects/resolver.go +++ b/projects/resolver.go @@ -132,6 +132,12 @@ func BuildWithStore(roots, projects []string, global, store string) (*Resolver, child.Name, rp)) continue } + if storePath != "" && child.Name == ".index" { + warnings = append(warnings, fmt.Sprintf( + "skipping %s under root %s: name collides with the --store index directory", + child.Name, rp)) + continue + } refs = append(refs, child) } if hasOrgKnowledge(rp, storePath) { @@ -245,13 +251,21 @@ func BuildWithStore(roots, projects []string, global, store string) (*Resolver, for _, ref := range r.refs { switch ref.Kind { case KindProject: - info, err := os.Stat(filepath.Join(ref.Path, ".agents")) + inTree := filepath.Join(ref.Path, ".agents") + if r.AgentsDir(ref) == inTree { + continue + } + info, err := os.Stat(inTree) if err == nil && info.IsDir() { warnings = append(warnings, fmt.Sprintf( "ignoring in-tree .agents at %s (--store is set)", ref.Path)) } case KindOrg: - info, err := os.Stat(filepath.Join(ref.Path, ".agents", "knowledge")) + inTree := filepath.Join(ref.Path, ".agents", "knowledge") + if r.OrgKnowledgeDir(ref) == inTree { + continue + } + info, err := os.Stat(inTree) if err == nil && info.IsDir() { warnings = append(warnings, fmt.Sprintf( "ignoring in-tree org knowledge at %s (--store is set)", ref.Path)) @@ -413,6 +427,12 @@ func (r *Resolver) RefForPath(path string) (Ref, bool) { return ref, ok } +// CanonicalPath returns the canonical absolute form of path when it names +// an existing directory (symlinks resolved); otherwise ok is false. +func CanonicalPath(path string) (string, bool) { + return canonicalize(path) +} + // pathWithin reports whether path equals dir or lies under it. func pathWithin(path, dir string) bool { return path == dir || strings.HasPrefix(path, dir+string(filepath.Separator)) @@ -421,7 +441,7 @@ func pathWithin(path, dir string) bool { func (r *Resolver) resolveDynamicBare(name string) (Ref, bool) { for _, root := range r.roots { path := filepath.Join(root, name) - if info, err := os.Stat(path); err == nil && info.IsDir() && !r.underStore(path) { + if info, err := os.Stat(path); err == nil && info.IsDir() && r.dynamicPathOK(path, name) { return Ref{ Name: name, Qualifier: filepath.Base(root), Path: path, Kind: KindProject, Address: name, @@ -439,7 +459,7 @@ func (r *Resolver) resolveDynamicQualified(name string) (Ref, bool) { continue } path := filepath.Join(root, base) - if info, err := os.Stat(path); err == nil && info.IsDir() && !r.underStore(path) { + if info, err := os.Stat(path); err == nil && info.IsDir() && r.dynamicPathOK(path, base) { return Ref{ Name: base, Qualifier: qualifier, Path: path, Kind: KindProject, Address: name, @@ -449,6 +469,20 @@ func (r *Resolver) resolveDynamicQualified(name string) (Ref, bool) { return Ref{}, false } +// dynamicPathOK reports whether a path found on disk after startup is +// eligible for dynamic resolution: paths under the central store are +// never resurrected, and a child named .index is excluded in store mode +// because its slot would live inside the removable index directory. +func (r *Resolver) dynamicPathOK(path, name string) bool { + if r.underStore(path) { + return false + } + if r.store != "" && name == ".index" { + return false + } + return true +} + // ScanRoot returns refs for the immediate child directories of root, // skipping .agents and .git and following symlinks. It returns warnings // for unreadable entries (e.g. permission errors, dangling symlinks). diff --git a/projects/resolver_test.go b/projects/resolver_test.go index 40158d5..3f707a1 100644 --- a/projects/resolver_test.go +++ b/projects/resolver_test.go @@ -576,3 +576,74 @@ func TestExplicitProjectInsideStoreKept(t *testing.T) { t.Errorf("expected explicit-project warning, got %v", warnings) } } + +func TestBuildWithStoreNoInTreeWarningForStoreSlot(t *testing.T) { + store := mkdir(t, t.TempDir(), "store") + + // Explicit project inside the store whose slot IS its in-tree dir. + proj := mkdir(t, store, "foo") + mkdir(t, proj, ".agents") + + res, warnings, err := BuildWithStore(nil, []string{proj}, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + for _, w := range warnings { + if strings.Contains(w, "ignoring in-tree .agents") { + t.Errorf("must not warn when the store slot is the in-tree dir: %q", w) + } + } + ref, err := res.Resolve("foo") + if err != nil { + t.Fatalf("Resolve(foo) error: %v", err) + } + if want := filepath.Join(proj, ".agents"); res.AgentsDir(ref) != want { + t.Errorf("AgentsDir = %q, want %q", res.AgentsDir(ref), want) + } + + // Org root inside the store whose slot IS its in-tree knowledge dir. + orgRoot := mkdir(t, store, "org") + mkdir(t, orgRoot, ".agents", "knowledge") + _, warnings, err = BuildWithStore([]string{orgRoot}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore(org) error: %v", err) + } + for _, w := range warnings { + if strings.Contains(w, "ignoring in-tree org knowledge") { + t.Errorf("must not warn when the org slot is the in-tree dir: %q", w) + } + } +} + +func TestBuildWithStoreSkipsIndexChild(t *testing.T) { + root := t.TempDir() + mkdir(t, root, "proj") + mkdir(t, root, ".index") + store := mkdir(t, t.TempDir(), "store") + + res, warnings, err := BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + if _, err := res.Resolve(".index"); err == nil { + t.Error("a child named .index must not become a project in store mode: its slot would live inside the removable index directory") + } + warned := false + for _, w := range warnings { + if strings.Contains(w, ".index") { + warned = true + } + } + if !warned { + t.Errorf("expected a .index collision warning, got %v", warnings) + } + + // Without a store, a real project named .index stays resolvable. + plain, _, err := Build([]string{root}, nil, "") + if err != nil { + t.Fatalf("Build() error: %v", err) + } + if _, err := plain.Resolve(".index"); err != nil { + t.Errorf("non-store mode must keep .index as a project: %v", err) + } +} diff --git a/tools/init.go b/tools/init.go index 2604ddd..f70bcb5 100644 --- a/tools/init.go +++ b/tools/init.go @@ -31,8 +31,11 @@ func InitHandler(res *projects.Resolver) func(context.Context, mcp.CallToolReque if res != nil && res.StoreEnabled() { ref, ok := res.RefForPath(projectPath) if !ok && res.Covers(projectPath) { - if resolved, resolveErr := res.Resolve(filepath.Base(projectPath)); resolveErr == nil { - ref, ok = resolved, true + if resolved, resolveErr := res.Resolve(filepath.Base(projectPath)); resolveErr == nil && + resolved.Kind == projects.KindProject { + if want, wantOk := projects.CanonicalPath(projectPath); wantOk && resolved.Path == want { + ref, ok = resolved, true + } } } if !ok { diff --git a/tools/tools_test.go b/tools/tools_test.go index f908d13..feec35c 100644 --- a/tools/tools_test.go +++ b/tools/tools_test.go @@ -1334,6 +1334,75 @@ func TestInitTargetsCentralStore(t *testing.T) { } } +func TestGlobalOpsUnderStore(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + knowledge.EnsureDir(filepath.Join(proj, ".agents")) + globalDir := t.TempDir() + knowledge.EnsureDir(filepath.Join(globalDir, ".agents")) + store := filepath.Join(t.TempDir(), "store") + knowledge.EnsureDir(store) + + resolver, _, err := projects.BuildWithStore([]string{root}, nil, globalDir, store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + write := WriteHandler(resolver, nil) + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "project": "_global", "category": "conventions", + "summary": "global under store", "detail": "global detail", "source": "test", + } + result, err := write(context.Background(), req) + if err != nil { + t.Fatalf("write error: %v", err) + } + if result.IsError { + t.Fatalf("write error result: %v", extractTextContent(t, result)) + } + + globalCat := knowledge.CategoryFilePath(filepath.Join(globalDir, ".agents"), "conventions") + if !knowledge.FileExists(globalCat) { + t.Fatal("write to _global must land in the global store") + } + if knowledge.FileExists(filepath.Join(store, "_global", ".agents", "conventions.yaml")) { + t.Error("write to _global must not create a _global slot in the central store") + } + + update := UpdateHandler(resolver, nil) + uReq := mcp.CallToolRequest{} + uReq.Params.Arguments = map[string]interface{}{ + "project": "_global", "category": "conventions", "id": "conv-001", "summary": "updated global under store", + } + uResult, err := update(context.Background(), uReq) + if err != nil { + t.Fatalf("update error: %v", err) + } + if uResult.IsError { + t.Fatalf("update error result: %v", extractTextContent(t, uResult)) + } + loaded, loadErr := knowledge.Load(globalCat) + if loadErr != nil { + t.Fatalf("load global file: %v", loadErr) + } + if loaded.Entries[0].Summary != "updated global under store" { + t.Errorf("summary = %q, want %q", loaded.Entries[0].Summary, "updated global under store") + } + + list := ListHandler(resolver, nil) + lReq := mcp.CallToolRequest{} + lReq.Params.Arguments = map[string]interface{}{"project": "_global"} + lResult, err := list(context.Background(), lReq) + if err != nil { + t.Fatalf("list error: %v", err) + } + content := extractTextContent(t, lResult) + if !strings.Contains(content, "updated global under store") { + t.Errorf("list _global should show the global entry: %s", content) + } +} + func TestInitUnresolvableUnderStore(t *testing.T) { root := t.TempDir() other := filepath.Join(t.TempDir(), "elsewhere") @@ -1363,3 +1432,112 @@ func TestInitUnresolvableUnderStore(t *testing.T) { t.Error("unresolvable init must not write in-tree .agents under --store") } } + +func TestInitStoreFallbackPathVerified(t *testing.T) { + t.Run("same name in another root must not bind", func(t *testing.T) { + r1 := t.TempDir() + r2 := t.TempDir() + existing := filepath.Join(r2, "api") + knowledge.EnsureDir(existing) // registered at build with unique bare name "api" + + store := filepath.Join(t.TempDir(), "store") + knowledge.EnsureDir(store) + + resolver, _, err := projects.BuildWithStore([]string{r1, r2}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + created := filepath.Join(r1, "api") + knowledge.EnsureDir(created) // created after startup, covered by r1 + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"project_path": created} + result, err := InitHandler(resolver)(context.Background(), req) + if err != nil { + t.Fatalf("init error: %v", err) + } + if !result.IsError { + t.Fatal("init for a path covered but not resolvable must reject, not write another root's slot") + } + content := extractTextContent(t, result) + if !strings.Contains(content, "--store") { + t.Errorf("error should explain --store, got: %s", content) + } + if knowledge.FileExists(filepath.Join(store, "api", ".agents")) { + t.Error("init must not write into the other root's store slot") + } + if knowledge.FileExists(filepath.Join(created, ".agents")) { + t.Error("init must not fall back to in-tree .agents under --store") + } + }) + + t.Run("org ref must not hijack init", func(t *testing.T) { + orgRoot := filepath.Join(t.TempDir(), "api") + knowledge.EnsureDir(orgRoot) + + store := filepath.Join(t.TempDir(), "store") + knowledge.EnsureDir(store) + // Org ref for "api" exists on the store side. + knowledge.EnsureDir(filepath.Join(store, "api", ".agents", "knowledge")) + + resolver, _, err := projects.BuildWithStore([]string{orgRoot}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + created := filepath.Join(orgRoot, "api") + knowledge.EnsureDir(created) // a project path under the root named like the org + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"project_path": created} + result, err := InitHandler(resolver)(context.Background(), req) + if err != nil { + t.Fatalf("init error: %v", err) + } + if !result.IsError { + t.Fatal("init must reject when bare-name resolution selects an unrelated org ref") + } + content := extractTextContent(t, result) + if strings.Contains(content, "org roots have no per-project store") { + t.Errorf("error must not blame the unrelated org ref, got: %s", content) + } + if !strings.Contains(content, "--store") { + t.Errorf("error should explain --store, got: %s", content) + } + if knowledge.FileExists(filepath.Join(created, ".agents")) { + t.Error("init must not write in-tree .agents under --store") + } + }) + + t.Run("new project under a root still initializes", func(t *testing.T) { + root := t.TempDir() + store := filepath.Join(t.TempDir(), "store") + knowledge.EnsureDir(store) + + resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store) + if err != nil { + t.Fatalf("BuildWithStore() error: %v", err) + } + + created := filepath.Join(root, "api") + knowledge.EnsureDir(created) // created after startup + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"project_path": created} + result, err := InitHandler(resolver)(context.Background(), req) + if err != nil { + t.Fatalf("init error: %v", err) + } + if result.IsError { + t.Fatalf("init returned error: %v", extractTextContent(t, result)) + } + want := filepath.Join(store, "api", ".agents") + if !knowledge.FileExists(knowledge.CategoryFilePath(want, "conventions")) { + t.Errorf("store not initialized at %s", want) + } + if knowledge.FileExists(filepath.Join(created, ".agents", "conventions.yaml")) { + t.Error("init must not create in-tree .agents under --store") + } + }) +}