Skip to content

Commit 545d2e1

Browse files
fix(sync): stabilize imports projects and tui ordering
1 parent ea1acda commit 545d2e1

9 files changed

Lines changed: 609 additions & 112 deletions

File tree

docs/AGENT-SETUP.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,17 @@ Engram works with **any MCP-compatible agent**. Pick your agent below.
2727

2828
### Project auto-detection (important)
2929

30-
**Do not pass `project` to write tools.** Engram auto-detects the project from the server's working directory (cwd) using git remote URL, repo root name, or directory basename. Agents that include `project` in `mem_save` or similar calls will have that argument silently discarded.
30+
**Do not pass `project` to write tools.** Engram auto-detects the project from the server's working directory (cwd) using `.engram/config.json`, git remote URL, repo root name, or directory basename. Agents that include `project` in `mem_save` or similar calls will have that argument silently discarded.
31+
32+
To lock write tools to the canonical project for a repo, add `.engram/config.json` at the repo root:
33+
34+
```json
35+
{
36+
"project_name": "sias-app"
37+
}
38+
```
39+
40+
When present, `project_name` is used for writes from the repo and its subdirectories and overrides lower-confidence cwd/git detection. This is a write lock only: read tools can still use an explicit `project` filter when you need to query another existing project. Empty or invalid `project_name` values fail writes loudly instead of falling back silently.
3141

3242
**Recommended first call:** `mem_current_project` — confirms which project Engram detected before you start writing. Returns `project_source` (how it was detected) and `available_projects` (if cwd is ambiguous).
3343

internal/mcp/mcp.go

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,12 +1001,7 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server
10011001
// Auto-detect project from cwd; fail fast on ambiguous (REQ-308, REQ-309)
10021002
detRes, err := resolveWriteProject()
10031003
if err != nil {
1004-
// JW1: use AvailableProjects from detection result (repos in cwd),
1005-
// NOT stats.Projects (all store projects).
1006-
return errorWithMeta("ambiguous_project",
1007-
fmt.Sprintf("Cannot determine project: %s", err),
1008-
detRes.AvailableProjects,
1009-
), nil
1004+
return writeProjectErrorResult(detRes, err), nil
10101005
}
10111006
project := detRes.Project
10121007

@@ -1241,11 +1236,7 @@ func handleSavePrompt(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc {
12411236

12421237
detRes, err := resolveWriteProject()
12431238
if err != nil {
1244-
// JW1: use AvailableProjects from detection result (repos in cwd).
1245-
return errorWithMeta("ambiguous_project",
1246-
fmt.Sprintf("Cannot determine project: %s", err),
1247-
detRes.AvailableProjects,
1248-
), nil
1239+
return writeProjectErrorResult(detRes, err), nil
12491240
}
12501241
project, _ := store.NormalizeProject(detRes.Project)
12511242

@@ -1523,11 +1514,7 @@ func handleSessionSummary(s *store.Store, cfg MCPConfig, activity *SessionActivi
15231514
// Auto-detect project from cwd; fail fast on ambiguous (REQ-308, REQ-309)
15241515
detRes, err := resolveWriteProject()
15251516
if err != nil {
1526-
// JW1: use AvailableProjects from detection result (repos in cwd).
1527-
return errorWithMeta("ambiguous_project",
1528-
fmt.Sprintf("Cannot determine project: %s", err),
1529-
detRes.AvailableProjects,
1530-
), nil
1517+
return writeProjectErrorResult(detRes, err), nil
15311518
}
15321519
project, _ := store.NormalizeProject(detRes.Project)
15331520

@@ -1567,11 +1554,7 @@ func handleSessionStart(s *store.Store, cfg MCPConfig, activity *SessionActivity
15671554

15681555
detRes, err := resolveSessionStartProject(explicitDirectory)
15691556
if err != nil {
1570-
// JW1: use AvailableProjects from detection result (repos in cwd).
1571-
return errorWithMeta("ambiguous_project",
1572-
fmt.Sprintf("Cannot determine project: %s", err),
1573-
detRes.AvailableProjects,
1574-
), nil
1557+
return writeProjectErrorResult(detRes, err), nil
15751558
}
15761559
project, _ := store.NormalizeProject(detRes.Project)
15771560

@@ -1614,6 +1597,9 @@ func handleSessionEnd(s *store.Store, cfg MCPConfig, activity *SessionActivity)
16141597

16151598
detRes, err := resolveWriteProject()
16161599
if err != nil {
1600+
if errors.Is(err, projectpkg.ErrInvalidConfig) {
1601+
return writeProjectErrorResult(detRes, err), nil
1602+
}
16171603
// For session end, still complete the operation even if project resolution fails.
16181604
// Use basename fallback.
16191605
cwd, _ := os.Getwd()
@@ -1645,11 +1631,7 @@ func handleCapturePassive(s *store.Store, cfg MCPConfig, activity *SessionActivi
16451631

16461632
detRes, err := resolveWriteProject()
16471633
if err != nil {
1648-
// JW1: use AvailableProjects from detection result (repos in cwd).
1649-
return errorWithMeta("ambiguous_project",
1650-
fmt.Sprintf("Cannot determine project: %s", err),
1651-
detRes.AvailableProjects,
1652-
), nil
1634+
return writeProjectErrorResult(detRes, err), nil
16531635
}
16541636
project, _ := store.NormalizeProject(detRes.Project)
16551637

@@ -1947,6 +1929,14 @@ func respondWithProject(res projectpkg.DetectionResult, text string, extra map[s
19471929
return mcp.NewToolResultText(string(out))
19481930
}
19491931

1932+
func writeProjectErrorResult(res projectpkg.DetectionResult, err error) *mcp.CallToolResult {
1933+
code := "ambiguous_project"
1934+
if errors.Is(err, projectpkg.ErrInvalidConfig) {
1935+
code = "invalid_project_config"
1936+
}
1937+
return errorWithMeta(code, fmt.Sprintf("Cannot determine project: %s", err), res.AvailableProjects)
1938+
}
1939+
19501940
// errorWithMeta returns a structured tool error result with error_code,
19511941
// message, available_projects, and a hint for resolution.
19521942
func errorWithMeta(code, msg string, availableProjects []string) *mcp.CallToolResult {
@@ -1960,6 +1950,8 @@ func errorWithMeta(code, msg string, availableProjects []string) *mcp.CallToolRe
19601950
envelope["hint"] = "Use mem_current_project to inspect detection results, or cd into one of the listed repositories."
19611951
case "unknown_project":
19621952
envelope["hint"] = "Use one of the available_projects values, or omit project to auto-detect."
1953+
case "invalid_project_config":
1954+
envelope["hint"] = "Fix .engram/config.json so project_name is a non-empty project name."
19631955
}
19641956
out, _ := jsonMarshal(envelope)
19651957
result := mcp.NewToolResultText(string(out))

internal/mcp/mcp_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3258,6 +3258,127 @@ func TestResolveWriteProject_AutoDetects(t *testing.T) {
32583258
}
32593259
}
32603260

3261+
func TestResolveWriteProject_UsesConfigFromRepoRootSubdir(t *testing.T) {
3262+
root := t.TempDir()
3263+
initTestGitRepo(t, root)
3264+
configDir := filepath.Join(root, ".engram")
3265+
if err := os.MkdirAll(configDir, 0o755); err != nil {
3266+
t.Fatal(err)
3267+
}
3268+
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(`{"project_name":"canonical-project"}`), 0o644); err != nil {
3269+
t.Fatal(err)
3270+
}
3271+
subdir := filepath.Join(root, "cmd", "tool")
3272+
if err := os.MkdirAll(subdir, 0o755); err != nil {
3273+
t.Fatal(err)
3274+
}
3275+
t.Chdir(subdir)
3276+
3277+
res, err := resolveWriteProject()
3278+
if err != nil {
3279+
t.Fatalf("resolveWriteProject: %v", err)
3280+
}
3281+
if res.Source != project.SourceConfig || res.Project != "canonical-project" {
3282+
t.Fatalf("expected config project, got source=%q project=%q", res.Source, res.Project)
3283+
}
3284+
}
3285+
3286+
func TestResolveWriteProject_InvalidConfigFailsClearly(t *testing.T) {
3287+
dir := t.TempDir()
3288+
configDir := filepath.Join(dir, ".engram")
3289+
if err := os.MkdirAll(configDir, 0o755); err != nil {
3290+
t.Fatal(err)
3291+
}
3292+
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(`{"project_name":"bad/name"}`), 0o644); err != nil {
3293+
t.Fatal(err)
3294+
}
3295+
t.Chdir(dir)
3296+
3297+
_, err := resolveWriteProject()
3298+
if !errors.Is(err, project.ErrInvalidConfig) || !strings.Contains(err.Error(), "project_name") {
3299+
t.Fatalf("expected clear invalid config project_name error, got %v", err)
3300+
}
3301+
}
3302+
3303+
func TestHandleSaveInvalidConfigFailsClearly(t *testing.T) {
3304+
dir := t.TempDir()
3305+
configDir := filepath.Join(dir, ".engram")
3306+
if err := os.MkdirAll(configDir, 0o755); err != nil {
3307+
t.Fatal(err)
3308+
}
3309+
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(`{"project_name":""}`), 0o644); err != nil {
3310+
t.Fatal(err)
3311+
}
3312+
t.Chdir(dir)
3313+
3314+
s := newMCPTestStore(t)
3315+
h := handleSave(s, MCPConfig{}, NewSessionActivity(10*time.Minute))
3316+
res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
3317+
"title": "should fail", "content": "invalid config", "type": "decision",
3318+
}}})
3319+
if err != nil {
3320+
t.Fatalf("handleSave: %v", err)
3321+
}
3322+
if !res.IsError {
3323+
t.Fatalf("expected invalid config to fail write, got %q", callResultText(t, res))
3324+
}
3325+
body := callResultJSON(t, res)
3326+
if body["error_code"] != "invalid_project_config" || !strings.Contains(body["message"].(string), "project_name") {
3327+
t.Fatalf("expected clear invalid project config error, got %v", body)
3328+
}
3329+
}
3330+
3331+
func TestHandleSaveAndPromptUseConfigProjectForWrites(t *testing.T) {
3332+
root := t.TempDir()
3333+
initTestGitRepo(t, root)
3334+
configDir := filepath.Join(root, ".engram")
3335+
if err := os.MkdirAll(configDir, 0o755); err != nil {
3336+
t.Fatal(err)
3337+
}
3338+
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(`{"project_name":"config-locked"}`), 0o644); err != nil {
3339+
t.Fatal(err)
3340+
}
3341+
subdir := filepath.Join(root, "internal", "pkg")
3342+
if err := os.MkdirAll(subdir, 0o755); err != nil {
3343+
t.Fatal(err)
3344+
}
3345+
t.Chdir(subdir)
3346+
3347+
s := newMCPTestStore(t)
3348+
save := handleSave(s, MCPConfig{}, NewSessionActivity(10*time.Minute))
3349+
res, err := save(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
3350+
"title": "config write", "content": "memory saved under config project", "type": "decision",
3351+
}}})
3352+
if err != nil || res.IsError {
3353+
t.Fatalf("mem_save failed: err=%v isError=%v text=%q", err, res.IsError, callResultText(t, res))
3354+
}
3355+
body := callResultJSON(t, res)
3356+
if body["project"] != "config-locked" || body["project_source"] != project.SourceConfig {
3357+
t.Fatalf("expected mem_save config envelope, got %v", body)
3358+
}
3359+
3360+
prompt := handleSavePrompt(s, MCPConfig{})
3361+
res, err = prompt(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
3362+
"content": "prompt saved under config project",
3363+
}}})
3364+
if err != nil || res.IsError {
3365+
t.Fatalf("mem_save_prompt failed: err=%v isError=%v text=%q", err, res.IsError, callResultText(t, res))
3366+
}
3367+
body = callResultJSON(t, res)
3368+
if body["project"] != "config-locked" || body["project_source"] != project.SourceConfig {
3369+
t.Fatalf("expected mem_save_prompt config envelope, got %v", body)
3370+
}
3371+
3372+
obs, err := s.Search("memory saved under config project", store.SearchOptions{Project: "config-locked", Limit: 5})
3373+
if err != nil || len(obs) != 1 {
3374+
t.Fatalf("expected observation written to config project, obs=%d err=%v", len(obs), err)
3375+
}
3376+
prompts, err := s.RecentPrompts("config-locked", 5)
3377+
if err != nil || len(prompts) != 1 {
3378+
t.Fatalf("expected prompt written to config project, prompts=%d err=%v", len(prompts), err)
3379+
}
3380+
}
3381+
32613382
// TestResolveWriteProject_AmbiguousError: assert errors.Is(err, ErrAmbiguousProject)
32623383
func TestResolveWriteProject_AmbiguousError(t *testing.T) {
32633384
parent := t.TempDir()

internal/project/detect.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ package project
77

88
import (
99
"context"
10+
"encoding/json"
1011
"errors"
12+
"fmt"
1113
"os"
1214
"os/exec"
1315
"path/filepath"
@@ -19,6 +21,10 @@ import (
1921
// multiple git repositories and we cannot auto-select one.
2022
var ErrAmbiguousProject = errors.New("ambiguous project: multiple git repos found in cwd")
2123

24+
// ErrInvalidConfig is returned when .engram/config.json exists but cannot be
25+
// used as a project write lock.
26+
var ErrInvalidConfig = errors.New("invalid .engram/config.json")
27+
2228
// Source constants describe how the project name was resolved.
2329
const (
2430
SourceGitRemote = "git_remote" // derived from git remote origin URL
@@ -28,6 +34,7 @@ const (
2834
SourceAmbiguous = "ambiguous" // cwd contains multiple git repos (Case 4)
2935
SourceExplicitOverride = "explicit_override" // JR2-2: caller explicitly supplied a project name
3036
SourceRequestBody = "request_body" // REQ-414: project came from the request body (server-side, no filesystem path)
37+
SourceConfig = "config" // derived from .engram/config.json project_name
3138
)
3239

3340
// noiseSet lists directory names that are skipped during child-repo scanning.
@@ -76,6 +83,10 @@ func DetectProjectFull(dir string) DetectionResult {
7683
dir = "./" + dir
7784
}
7885

86+
if res, ok := detectFromConfig(dir); ok {
87+
return res
88+
}
89+
7990
// ── Case 1: git_remote ──────────────────────────────────────────────
8091
if name := detectFromGitRemote(dir); name != "" {
8192
// JS2: use repo root as Path for consistency with Case 2 (git_root).
@@ -154,6 +165,75 @@ basename:
154165
}
155166
}
156167

168+
type configFile struct {
169+
ProjectName string `json:"project_name"`
170+
}
171+
172+
func detectFromConfig(dir string) (DetectionResult, bool) {
173+
absDir, err := filepath.Abs(dir)
174+
if err != nil {
175+
absDir = dir
176+
}
177+
178+
// Project config is a project/repo lock, not a global ancestor setting. When
179+
// cwd is inside git, only the repository root may define the lock. This keeps
180+
// ~/.engram/config.json (the global data directory) from being inherited by
181+
// every directory under $HOME.
182+
if gitRoot := detectGitRootDir(absDir); gitRoot != "" {
183+
return readConfigAt(gitRoot)
184+
}
185+
186+
// Outside git, accept only the current directory's config. Do not walk to
187+
// arbitrary parents such as $HOME.
188+
return readConfigAt(absDir)
189+
}
190+
191+
func readConfigAt(projectDir string) (DetectionResult, bool) {
192+
configPath := filepath.Join(projectDir, ".engram", "config.json")
193+
data, err := os.ReadFile(configPath)
194+
if err != nil {
195+
if errors.Is(err, os.ErrNotExist) {
196+
return DetectionResult{}, false
197+
}
198+
return invalidConfigResult(projectDir, fmt.Errorf("read %s: %w", configPath, err)), true
199+
}
200+
201+
var cfg configFile
202+
if err := json.Unmarshal(data, &cfg); err != nil {
203+
return invalidConfigResult(projectDir, fmt.Errorf("parse %s: %w", configPath, err)), true
204+
}
205+
projectName, err := normalizeConfigProjectName(cfg.ProjectName)
206+
if err != nil {
207+
return invalidConfigResult(projectDir, err), true
208+
}
209+
return DetectionResult{Project: projectName, Source: SourceConfig, Path: projectDir}, true
210+
}
211+
212+
func invalidConfigResult(path string, err error) DetectionResult {
213+
return DetectionResult{
214+
Project: "",
215+
Source: SourceConfig,
216+
Path: path,
217+
Error: fmt.Errorf("%w: %v", ErrInvalidConfig, err),
218+
}
219+
}
220+
221+
func normalizeConfigProjectName(projectName string) (string, error) {
222+
trimmed := strings.TrimSpace(projectName)
223+
if trimmed == "" {
224+
return "", fmt.Errorf("%w: project_name is required", ErrInvalidConfig)
225+
}
226+
if strings.ContainsAny(trimmed, `/\\`) {
227+
return "", fmt.Errorf("%w: project_name must be a name, not a path", ErrInvalidConfig)
228+
}
229+
for _, r := range trimmed {
230+
if r < 0x20 || r == 0x7f {
231+
return "", fmt.Errorf("%w: project_name contains control characters", ErrInvalidConfig)
232+
}
233+
}
234+
return normalize(trimmed), nil
235+
}
236+
157237
// detectGitRootDir returns the git repository root for dir, or "" if not in a repo.
158238
func detectGitRootDir(dir string) string {
159239
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)

0 commit comments

Comments
 (0)