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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/engram/hook_claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ var claudeHookOutput = func(response []byte) error {
}

func cmdHook(args []string) {
if len(args) == 1 && args[0] == "resolve-hook-project" {
cmdResolveHookProject()
return
}
if len(args) == 1 && (args[0] == "codex-register" || args[0] == "codex-resolve" || args[0] == "codex-session-end") {
cmdCodexLifecycle(args[0])
return
Expand All @@ -70,7 +74,7 @@ func cmdHook(args []string) {
return
}
if len(args) != 1 || (args[0] != "claude-pre-tool-use" && args[0] != "codex-pre-tool-use") {
fmt.Fprintln(os.Stderr, "usage: engram hook claude-pre-tool-use|codex-pre-tool-use|codex-user-prompt-submit")
fmt.Fprintln(os.Stderr, "usage: engram hook claude-pre-tool-use|codex-pre-tool-use|codex-user-prompt-submit|resolve-hook-project")
exitFunc(1)
return
}
Expand Down
73 changes: 73 additions & 0 deletions cmd/engram/hook_project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package main

import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"unicode/utf8"

projectpkg "github.com/Gentleman-Programming/engram/v3/internal/project"
)

// cmdResolveHookProject is config-free like the other native hook actions. Its
// standalone inspection fits the tightest native prompt budget; native callers
// use InspectLocalProject directly with their own remaining context instead.
func cmdResolveHookProject() {
ctx, cancel := context.WithTimeout(context.Background(), codexUserPromptDeadline)
defer cancel()
output := map[string]string{}
input, err := io.ReadAll(os.Stdin)
if err == nil {
if cwd, ok := hookProjectCWD(input); ok && filepath.IsAbs(cwd) {
if result, err := projectpkg.InspectLocalProject(ctx, cwd); err == nil {
output["project"] = result.Project
output["project_source"] = result.Source
output["project_path"] = result.Path
}
}
}
if err := json.NewEncoder(os.Stdout).Encode(output); err != nil {
exitFunc(1)
}
}

// hookProjectCWD accepts existing hook fields but gives identity to exactly one
// lowercase cwd field. A map/struct decoder would silently accept duplicate keys.
func hookProjectCWD(input []byte) (string, bool) {
if !utf8.Valid(input) {
return "", false
}
decoder := json.NewDecoder(bytes.NewReader(input))
start, err := decoder.Token()
if err != nil || start != json.Delim('{') {
return "", false
}
var cwd string
seen := false
for decoder.More() {
key, err := decoder.Token()
if err != nil {
return "", false
}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return "", false
}
if key == "cwd" {
if seen || json.Unmarshal(value, &cwd) != nil {
return "", false
}
seen = true
}
}
if end, err := decoder.Token(); err != nil || end != json.Delim('}') {
return "", false
}
if err := decoder.Decode(new(any)); err != io.EOF {
return "", false
}
return cwd, seen
}
269 changes: 269 additions & 0 deletions cmd/engram/hook_project_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"

projectpkg "github.com/Gentleman-Programming/engram/v3/internal/project"
)

func TestHookResolveHookProject(t *testing.T) {
binary := buildCompatibilityBinary(t)
var requests atomic.Int32
trap := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer trap.Close()

for _, name := range []string{
"configured", "unicode-config", "non-git", "relative-cwd", "relative-dot-cwd", "bound-git", "linked-worktree",
"unbound-git", "unbound-child", "ambiguous", "invalid-config", "missing-directory", "file-directory",
"broken-gitfile", "broken-git-directory", "broken-git-child", "broken-git-descendant",
"malformed-json", "invalid-utf8", "missing-cwd", "null-cwd", "number-cwd", "blank-cwd", "nul-cwd", "wrong-case-cwd", "duplicate-cwd",
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
env := hookProjectFixtureEnvironment(t, root, trap.URL)
cwd := filepath.Join(root, "non-git")
if strings.HasPrefix(name, "broken-git") {
cwd = filepath.Join(root, "feature-x")
}
if err := os.Mkdir(cwd, 0o700); err != nil {
t.Fatal(err)
}
wantProject, wantSource := "", ""
switch name {
case "configured", "unicode-config":
value := "MyProj"
wantProject = "myproj"
if name == "unicode-config" {
value, wantProject = "Cafe\u0301", "cafΓ©"
}
hookProjectFixtureConfig(t, cwd, value)
wantSource = "config"
case "non-git":
wantProject, wantSource = "non-git", "dir_basename"
case "relative-cwd":
cwd = "sub/dir"
if err := os.MkdirAll(filepath.Join(root, "sub", "dir"), 0o700); err != nil {
t.Fatal(err)
}
case "relative-dot-cwd":
hookProjectFixtureConfig(t, root, "ambient-project")
cwd = "."
case "bound-git", "linked-worktree", "unbound-git":
hookProjectFixtureGit(t, env, "init", "-q", cwd)
hookProjectFixtureGit(t, env, "-C", cwd, "remote", "add", "origin", "https://example.invalid/canonical-bound.git")
if name != "unbound-git" {
// Seed a binding through the existing public detector, not a private format.
resolved := projectpkg.DetectProjectFull(cwd)
if resolved.Error != nil || resolved.Project != "canonical-bound" {
t.Fatalf("fixture binding: %+v", resolved)
}
wantProject, wantSource = "canonical-bound", "git_remote"
}
if name == "linked-worktree" {
hookProjectFixtureGit(t, env, "-C", cwd, "-c", "user.name=Fixture", "-c", "[email protected]", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "-m", "fixture")
linked := filepath.Join(root, "linked")
hookProjectFixtureGit(t, env, "-C", cwd, "worktree", "add", "-q", "--detach", linked)
cwd = linked
}
case "unbound-child", "ambiguous":
hookProjectFixtureGit(t, env, "init", "-q", filepath.Join(cwd, "first"))
if name == "ambiguous" {
hookProjectFixtureGit(t, env, "init", "-q", filepath.Join(cwd, "second"))
}
case "broken-gitfile":
if err := os.WriteFile(filepath.Join(cwd, ".git"), []byte("gitdir: ../missing-metadata\n"), 0o600); err != nil {
t.Fatal(err)
}
case "broken-git-directory", "broken-git-child", "broken-git-descendant":
marker := filepath.Join(cwd, ".git")
if name == "broken-git-child" {
marker = filepath.Join(cwd, "child", ".git")
}
if err := os.MkdirAll(marker, 0o700); err != nil {
t.Fatal(err)
}
if name == "broken-git-descendant" {
cwd = filepath.Join(cwd, "nested")
if err := os.Mkdir(cwd, 0o700); err != nil {
t.Fatal(err)
}
}
case "invalid-config":
hookProjectFixtureConfig(t, cwd, "../invalid")
case "missing-directory":
cwd = filepath.Join(root, "absent", "feature-x")
case "file-directory":
cwd = filepath.Join(root, "not-a-directory")
if err := os.WriteFile(cwd, []byte("sentinel"), 0o600); err != nil {
t.Fatal(err)
}
}
if strings.HasPrefix(name, "broken-git") {
// The strict new inspector must not change ordinary detection.
legacyBefore := compatibilityTreeHash(t, root)
legacy := projectpkg.DetectProjectFull(cwd)
project, source, path, warning := filepath.Base(cwd), "dir_basename", cwd, ""
if name == "broken-git-child" {
project, source, path = "child", "git_child", filepath.Join(cwd, "child")
warning = "auto-promoted child repository: child"
}
if legacy.Error != nil || legacy.Project != project || legacy.Source != source || legacy.Path != path || legacy.Warning != warning || len(legacy.AvailableProjects) != 0 {
t.Fatalf("ordinary detector changed: %+v", legacy)
}
if compatibilityTreeHash(t, root) != legacyBefore {
t.Fatal("ordinary malformed-Git detection changed fixture state")
}
}
input, err := json.Marshal(map[string]any{"cwd": cwd, "session_id": "host", "private_field": "never-echo-this"})
if err != nil {
t.Fatal(err)
}
switch name {
case "malformed-json":
input = []byte(`{"cwd":`)
case "invalid-utf8":
input = []byte{'{', '"', 'c', 'w', 'd', '"', ':', '"', 0xff, '"', '}'}
case "missing-cwd":
input = []byte(`{"session_id":"host"}`)
case "null-cwd":
input = []byte(`{"cwd":null}`)
case "number-cwd":
input = []byte(`{"cwd":7}`)
case "blank-cwd":
input = []byte(`{"cwd":" "}`)
case "nul-cwd":
input = []byte(`{"cwd":"bad\u0000path"}`)
case "wrong-case-cwd":
input, _ = json.Marshal(map[string]string{"CWD": cwd})
case "duplicate-cwd":
encoded, _ := json.Marshal(cwd)
input = []byte(fmt.Sprintf(`{"cwd":%s,"cwd":%s}`, encoded, encoded))
}
before := compatibilityTreeHash(t, root)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binary, "hook", "resolve-hook-project")
cmd.Dir, cmd.Env, cmd.Stdin = root, env, bytes.NewReader(input)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
runErr := cmd.Run()
if runErr != nil || stderr.Len() != 0 {
t.Errorf("exit=%v stdout=%q stderr=%q; want exit 0 and empty stderr", runErr, stdout.String(), stderr.String())
}
if wantProject == "" {
if stdout.String() != "{}\n" {
t.Errorf("stdout=%q, want exactly {} followed by newline", stdout.String())
}
} else {
var result struct {
Project string `json:"project"`
Source string `json:"project_source"`
Path string `json:"project_path"`
}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil || result.Project != wantProject || result.Source != wantSource {
t.Errorf("response=%q error=%v, want project=%q source=%q", stdout.String(), err, wantProject, wantSource)
}
// A linked worktree shares the primary repository's identity path;
// the other positive fixtures resolve to this directory directly.
wantInfo, wantErr := os.Stat(filepath.Join(root, "non-git"))
gotInfo, pathErr := os.Stat(result.Path)
if wantErr != nil || pathErr != nil || !os.SameFile(wantInfo, gotInfo) {
t.Errorf("project_path=%q errors=%v/%v, want the fixture identity directory", result.Path, wantErr, pathErr)
}
if strings.Contains(stdout.String(), "never-echo-this") {
t.Error("response leaked unrelated hook input")
}
}
if after := compatibilityTreeHash(t, root); after != before {
t.Error("inspection changed fixture HOME, data, config or Git state")
}
if requests.Load() != 0 {
t.Errorf("HTTP requests=%d, want 0", requests.Load())
}
})
}

t.Run("existing-read-only-hook", func(t *testing.T) {
root := t.TempDir()
env := hookProjectFixtureEnvironment(t, root, trap.URL)
before := compatibilityTreeHash(t, root)
cmd := exec.Command(binary, "hook", "claude-pre-tool-use")
cmd.Dir, cmd.Env = root, env
cmd.Stdin = strings.NewReader(`{"tool_name":"mcp__other__read","session_id":"host","tool_input":{}}`)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil || stdout.String() != "{}" || stderr.Len() != 0 {
t.Fatalf("existing hook exit=%v stdout=%q stderr=%q", err, stdout.String(), stderr.String())
}
if compatibilityTreeHash(t, root) != before || requests.Load() != 0 {
t.Fatal("existing read-only hook caused side effects")
}
})
}

func hookProjectFixtureConfig(t *testing.T, cwd, name string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(cwd, ".engram"), 0o700); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(map[string]string{"project_name": name})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cwd, ".engram", "config.json"), data, 0o600); err != nil {
t.Fatal(err)
}
}

func hookProjectFixtureEnvironment(t *testing.T, root, endpoint string) []string {
t.Helper()
env := make([]string, 0, 24)
for _, key := range []string{"PATH", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"} {
if value, ok := os.LookupEnv(key); ok {
env = append(env, key+"="+value)
}
}
for _, key := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "TEMP", "TMP", "TMPDIR", "ENGRAM_DATA_DIR"} {
dir := filepath.Join(root, strings.ToLower(key))
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
env = append(env, key+"="+dir)
if key == "HOME" || key == "USERPROFILE" {
t.Setenv(key, dir)
}
}
globalConfig := filepath.Join(root, "git-global-config")
if err := os.WriteFile(globalConfig, nil, 0o600); err != nil {
t.Fatal(err)
}
for key, value := range map[string]string{"GIT_CONFIG_GLOBAL": globalConfig, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_COUNT": "0", "GIT_CEILING_DIRECTORIES": root} {
env = append(env, key+"="+value)
t.Setenv(key, value)
}
return append(env, "ENGRAM_URL="+endpoint, "ENGRAM_NO_UPDATE_CHECK=1", "ENGRAM_CLOUD_AUTOSYNC=0")
}

func hookProjectFixtureGit(t *testing.T, env []string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Env = env
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, output)
}
}
2 changes: 2 additions & 0 deletions cmd/engram/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3927,6 +3927,8 @@ Commands:
import <file> Import memories from a JSON export file
init [name] Initialize an Engram project (.engram/config.json) in current directory
--force, -f Overwrite existing .engram/config.json
hook resolve-hook-project
Inspect the absolute cwd in hook JSON on stdin without HTTP, database or binding writes
projects list List all projects with observation, session, and prompt counts
projects merge --from <source> --to <canonical> (--dry-run|--apply)
Preview or apply a separator or Unicode normalization variant merge
Expand Down
6 changes: 6 additions & 0 deletions docs/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
| Codex | Codex plugin assets under `plugin/codex/`; `engram setup codex` best-effort installs the marketplace plugin and writes MCP/instruction config. |
| Pi | Pi package under `plugin/pi/` exposes Pi-native HTTP memory tools; Pi 0.99.0+ built-in MCP handles other servers, and an installed `pi-mcp-adapter` replaces it, so setup does not add it. |

### Client-local project inspection

`engram hook resolve-hook-project` reads existing hook JSON from stdin and uses its exact lowercase `cwd` field to inspect the local filesystem. The `cwd` must be an absolute path; relative paths return `{}`. Other hook fields are ignored. It prints canonical `project`, `project_source` and `project_path` JSON, or `{}` when input or identity is unusable. Malformed input, duplicate `cwd`, inaccessible directories, ambiguity and invalid/unbound Git identities never fall back to an HTTP lookup. Malformed Git metadata in the cwd, its ancestors or a promoted child is not treated as a plain directory. Readable non-Git directories retain basename resolution.

Inspection does not open the database, initialize global configuration, create Git bindings or register sessions. Existing config and repository bindings remain authoritative. The standalone action shares the native Codex prompt's 1.5-second inspection budget; internal callers can instead pass their remaining context to the shared Go inspector. This helper alone does not change the registration hooks or repair already misattributed sessions.

Pi and OpenCode activity renews the local runtime lease through their existing session registration paths. This is local SQLite liveness only: it has no timer, cloud synchronization, or cross-machine coordination.

Pi sends `resume: true` for runtime-root registration and adopts only a valid acknowledged effective ID. The Go core selects numeric continuations; Pi persists the mapping in its session entries for reloads and uses it for attributed writes and shutdown `/end`. Legacy `:resume:<uuid>` mappings are re-registered as-is; an ended mapping falls back to root registration with `resume: true`. Reload does not end the session. Ownership conflicts and an older server's `409 session_already_ended` block writes rather than triggering client-generated identities.
Expand Down
Loading
Loading