Skip to content
Open
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
161 changes: 145 additions & 16 deletions internal/setup/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2331,25 +2331,154 @@ func TestClaudeCodeMemorySkillDoesNotHardcodePluginScopedToolSearch(t *testing.T
}
}

func TestClaudeCodeUserPromptHookUsesCurrentMCPServerID(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "plugin", "claude-code", "scripts", "user-prompt-submit.sh"))
if err != nil {
t.Fatalf("read user prompt hook: %v", err)
// claudeCodeBootstrapTools is the complete tool set the first-message
// ToolSearch bootstrap must load. Both hook implementations (bash and the
// Windows PowerShell fallback) must list every one of these under both
// install-mode prefixes; a partial list lets a dropped tool regress silently.
var claudeCodeBootstrapTools = []string{
"mem_save", "mem_search", "mem_context", "mem_session_summary",
"mem_session_start", "mem_session_end", "mem_get_observation",
"mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt",
"mem_update", "mem_current_project", "mem_judge",
}

// readUserPromptHooks returns both user prompt hook sources (bash and the
// Windows PowerShell fallback) for the ToolSearch prefix assertions below.
func readUserPromptHooks(t *testing.T) map[string]string {
t.Helper()
hooks := make(map[string]string, 2)
for _, name := range []string{"user-prompt-submit.sh", "user-prompt-submit.ps1"} {
data, err := os.ReadFile(filepath.Join("..", "..", "plugin", "claude-code", "scripts", name))
if err != nil {
t.Fatalf("read user prompt hook %s: %v", name, err)
}
hooks[name] = string(data)
}
return hooks
}

// Claude Code exposes engram's MCP tools under two different name prefixes
// depending on how engram was installed, and the first-message ToolSearch
// bootstrap must load them in BOTH cases. #192 ("repair Claude MCP tool
// discovery", commit 3b99f0a) narrowed the select: list to the direct-MCP
// prefix only, which silently broke the plugin/marketplace install path. The
// two tests below lock each install mode independently so a regression names the
// exact mode it broke. Listing both prefixes is safe: ToolSearch select: loads
// whichever names exist and ignores the rest.

// Direct MCP-server install (server id "engram") β†’ mcp__engram__*.
func TestClaudeCodeUserPromptHookCoversDirectMCPServerID(t *testing.T) {
for name, text := range readUserPromptHooks(t) {
listed := toolSearchSelectSet(t, text)
for _, tool := range claudeCodeBootstrapTools {
want := "mcp__engram__" + tool
if !listed[want] {
t.Errorf("%s: ToolSearch select: list is missing direct-MCP name %q", name, want)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
text := string(data)
if strings.Contains(text, "select:mcp__plugin_engram_engram__") {
t.Fatalf("user prompt hook must not hardcode plugin-scoped ToolSearch names")
}

// Plugin/marketplace install β†’ mcp__plugin_engram_engram__*.
func TestClaudeCodeUserPromptHookCoversPluginServerID(t *testing.T) {
for name, text := range readUserPromptHooks(t) {
listed := toolSearchSelectSet(t, text)
for _, tool := range claudeCodeBootstrapTools {
want := "mcp__plugin_engram_engram__" + tool
if !listed[want] {
t.Errorf("%s: ToolSearch select: list is missing plugin-scoped name %q", name, want)
}
}
}
for _, tool := range []string{
"mcp__engram__mem_save",
"mcp__engram__mem_search",
"mcp__engram__mem_context",
"mcp__engram__mem_current_project",
"mcp__engram__mem_judge",
} {
if !strings.Contains(text, tool) {
t.Fatalf("user prompt hook missing current ToolSearch name %q", tool)
}

// toolSearchSelectSet parses every select: occurrence in script source and
// returns the token set with the most entries. Script source may contain
// comments β€” a prose comment containing "select:" might yield zero or one
// token, whereas the real ToolSearch list yields 26+ tokens. Taking the
// largest set avoids accidental coupling to comment content: a future comment
// containing "select:mcp__" before the real list would not fool this parser.
//
// For each candidate, the list is scanned forward from the "select:" marker
// while characters remain valid in a tool name or the comma separator. That
// stops at the escape sequence terminating the list in both sources: a literal
// `\n` in user-prompt-submit.sh and a backtick-n in user-prompt-submit.ps1.
func toolSearchSelectSet(t *testing.T, script string) map[string]bool {
t.Helper()
var largest map[string]bool
offset := 0
for {
idx := strings.Index(script[offset:], "select:")
if idx < 0 {
break
}
idx += offset
rest := script[idx+len("select:"):]
end := strings.IndexFunc(rest, func(r rune) bool {
switch {
case r == ',' || r == '_':
return false
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return false
}
return true
})
if end < 0 {
end = len(rest)
}
set := make(map[string]bool)
for _, name := range strings.Split(rest[:end], ",") {
if name != "" {
set[name] = true
}
}
if len(set) > len(largest) {
largest = set
}
offset = idx + len("select:")
}
if len(largest) == 0 {
t.Fatal("hook script has no ToolSearch select: list")
}
return largest
}

// mem_save is a prefix of mem_save_prompt, so a Contains-based assertion reports
// mem_save as present when only mem_save_prompt is listed. That is the hole this
// parser exists to close.
func TestToolSearchSelectSetRequiresExactNames(t *testing.T) {
set := toolSearchSelectSet(t, `select:mcp__engram__mem_save_prompt,mcp__engram__mem_search\n\nAfter loading`)

if set["mcp__engram__mem_save"] {
t.Error("mem_save_prompt must not satisfy a required mem_save entry")
}
if !set["mcp__engram__mem_save_prompt"] {
t.Error("parser dropped mcp__engram__mem_save_prompt")
}
if !set["mcp__engram__mem_search"] {
t.Error("parser dropped mcp__engram__mem_search")
}
if len(set) != 2 {
t.Errorf("parser returned %d names, want 2: %v", len(set), set)
}
}

// A comment containing the marker before the real list must not fool the
// parser: it scans every select: and takes the largest token set, so a prose
// occurrence (zero or one token) never wins over the real 26-name list.
func TestToolSearchSelectSetIgnoresMarkerInComment(t *testing.T) {
script := `# see the select:mcp__ list below for details` + "\n" +
`printf '{"...":"select:mcp__engram__mem_save,mcp__engram__mem_search,mcp__engram__mem_context"}'`
set := toolSearchSelectSet(t, script)

if !set["mcp__engram__mem_save"] || !set["mcp__engram__mem_search"] || !set["mcp__engram__mem_context"] {
t.Errorf("parser did not extract the real list; got %v", set)
}
if set["mcp__engram__mem_save_prompt"] {
t.Error("parser invented a token not in the list")
}
if len(set) != 3 {
t.Errorf("parser returned %d names, want 3: %v", len(set), set)
}
}

Expand Down
2 changes: 1 addition & 1 deletion plugin/claude-code/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"hooks": {
"SessionStart": [
{
"matcher": "startup|clear",
"matcher": "startup|resume|clear|fork",
"hooks": [
{
"type": "command",
Expand Down
6 changes: 5 additions & 1 deletion plugin/claude-code/scripts/subagent-stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ source "${SCRIPT_DIR}/_helpers.sh"
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
OUTPUT=$(echo "$INPUT" | jq -r '.stdout // empty')
# Claude Code's SubagentStop payload carries the subagent's final text in
# last_assistant_message; there is no .stdout field, so reading .stdout captured
# nothing and every subagent run no-op'd. Keep .stdout as a fallback for other
# harnesses that reuse this script (parity with plugin/codex/scripts).
OUTPUT=$(echo "$INPUT" | jq -r '.last_assistant_message // .stdout // empty')
PROJECT=$(detect_project "$CWD")

# Nothing to capture if no output
Expand Down
22 changes: 19 additions & 3 deletions plugin/claude-code/scripts/user-prompt-submit.ps1
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env pwsh
# Engram β€” Windows-native UserPromptSubmit hook for Claude Code
# Engram - Windows-native UserPromptSubmit hook for Claude Code
#
# Optional fallback for enterprise Windows environments where Git Bash/MSYS2
# fork emulation is slowed or blocked by Defender/EDR. Keep this script small
Expand All @@ -18,9 +18,25 @@ function Write-EmptyHookResponse {
Write-Output '{}'
}

# Emit the first-message ToolSearch bootstrap.
#
# Uses hookSpecificOutput.additionalContext (NOT systemMessage): on a
# UserPromptSubmit hook, only stdout/additionalContext enters the model's
# context. A systemMessage payload is rendered to the terminal as
# "UserPromptSubmit says: ..." (issue #145) and never reaches the model.
#
# The select list carries BOTH tool-name prefixes: mcp__plugin_engram_engram__*
# for plugin/marketplace installs and mcp__engram__* for direct MCP-server
# installs. ToolSearch select: returns whichever names exist and silently
# ignores the rest, so one list covers both install modes (issue #534).
function Write-ToolSearchMessage {
$message = "CRITICAL FIRST ACTION β€” Execute this ToolSearch NOW before responding to the user:`nselect:mcp__engram__mem_save,mcp__engram__mem_search,mcp__engram__mem_context,mcp__engram__mem_session_summary,mcp__engram__mem_session_start,mcp__engram__mem_session_end,mcp__engram__mem_get_observation,mcp__engram__mem_suggest_topic_key,mcp__engram__mem_capture_passive,mcp__engram__mem_save_prompt,mcp__engram__mem_update,mcp__engram__mem_current_project,mcp__engram__mem_judge`n`nAfter loading tools, call mem_context to check for prior session history before responding."
[PSCustomObject]@{ systemMessage = $message } | ConvertTo-Json -Compress
$message = "CRITICAL FIRST ACTION - Execute this ToolSearch NOW before responding to the user:`nselect:mcp__plugin_engram_engram__mem_save,mcp__plugin_engram_engram__mem_search,mcp__plugin_engram_engram__mem_context,mcp__plugin_engram_engram__mem_session_summary,mcp__plugin_engram_engram__mem_session_start,mcp__plugin_engram_engram__mem_session_end,mcp__plugin_engram_engram__mem_get_observation,mcp__plugin_engram_engram__mem_suggest_topic_key,mcp__plugin_engram_engram__mem_capture_passive,mcp__plugin_engram_engram__mem_save_prompt,mcp__plugin_engram_engram__mem_update,mcp__plugin_engram_engram__mem_current_project,mcp__plugin_engram_engram__mem_judge,mcp__engram__mem_save,mcp__engram__mem_search,mcp__engram__mem_context,mcp__engram__mem_session_summary,mcp__engram__mem_session_start,mcp__engram__mem_session_end,mcp__engram__mem_get_observation,mcp__engram__mem_suggest_topic_key,mcp__engram__mem_capture_passive,mcp__engram__mem_save_prompt,mcp__engram__mem_update,mcp__engram__mem_current_project,mcp__engram__mem_judge`n`nAfter loading tools, call mem_context to check for prior session history before responding."
[PSCustomObject]@{
hookSpecificOutput = [PSCustomObject]@{
hookEventName = 'UserPromptSubmit'
additionalContext = $message
}
} | ConvertTo-Json -Compress
}

function Invoke-EngramPromptPersist {
Expand Down
20 changes: 17 additions & 3 deletions plugin/claude-code/scripts/user-prompt-submit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,20 @@ sanitize_session_key_part() {
JSON_VALUE="$safe"
}

# Emit the first-message ToolSearch bootstrap.
#
# Uses hookSpecificOutput.additionalContext (NOT systemMessage): on a
# UserPromptSubmit hook, only stdout/additionalContext enters the model's
# context. A systemMessage payload is rendered to the terminal as
# "UserPromptSubmit says: ..." (issue #145) and never reaches the model, so the
# bootstrap silently no-ops. session-start.sh already uses additionalContext.
#
# The select list carries BOTH tool-name prefixes: mcp__plugin_engram_engram__*
# for plugin/marketplace installs and mcp__engram__* for direct MCP-server
# installs. ToolSearch select: returns whichever names exist and silently
# ignores the rest, so one list covers both install modes (issue #534).
print_toolsearch_message() {
printf '%s\n' '{"systemMessage":"CRITICAL FIRST ACTION β€” Execute this ToolSearch NOW before responding to the user:\nselect:mcp__engram__mem_save,mcp__engram__mem_search,mcp__engram__mem_context,mcp__engram__mem_session_summary,mcp__engram__mem_session_start,mcp__engram__mem_session_end,mcp__engram__mem_get_observation,mcp__engram__mem_suggest_topic_key,mcp__engram__mem_capture_passive,mcp__engram__mem_save_prompt,mcp__engram__mem_update,mcp__engram__mem_current_project,mcp__engram__mem_judge\n\nAfter loading tools, call mem_context to check for prior session history before responding."}'
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"CRITICAL FIRST ACTION - Execute this ToolSearch NOW before responding to the user:\nselect:mcp__plugin_engram_engram__mem_save,mcp__plugin_engram_engram__mem_search,mcp__plugin_engram_engram__mem_context,mcp__plugin_engram_engram__mem_session_summary,mcp__plugin_engram_engram__mem_session_start,mcp__plugin_engram_engram__mem_session_end,mcp__plugin_engram_engram__mem_get_observation,mcp__plugin_engram_engram__mem_suggest_topic_key,mcp__plugin_engram_engram__mem_capture_passive,mcp__plugin_engram_engram__mem_save_prompt,mcp__plugin_engram_engram__mem_update,mcp__plugin_engram_engram__mem_current_project,mcp__plugin_engram_engram__mem_judge,mcp__engram__mem_save,mcp__engram__mem_search,mcp__engram__mem_context,mcp__engram__mem_session_summary,mcp__engram__mem_session_start,mcp__engram__mem_session_end,mcp__engram__mem_get_observation,mcp__engram__mem_suggest_topic_key,mcp__engram__mem_capture_passive,mcp__engram__mem_save_prompt,mcp__engram__mem_update,mcp__engram__mem_current_project,mcp__engram__mem_judge\n\nAfter loading tools, call mem_context to check for prior session history before responding."}}'
}

if is_windows_bash && [ "${ENGRAM_CLAUDE_WINDOWS_BASH_SAFE_MODE:-auto}" != "0" ]; then
Expand Down Expand Up @@ -269,9 +281,11 @@ if [ "$ELAPSED" -gt 900 ]; then
esac

if [ -z "$LAST_NUDGE_EPOCH" ] || [ "$(( NOW_EPOCH - LAST_NUDGE_EPOCH ))" -ge "$NUDGE_COOLDOWN" ]; then
printf '%s' "$NOW_EPOCH" > "$NUDGE_STATE_FILE" 2>/dev/null || true
printf '%s\n' "$NOW_EPOCH" > "$NUDGE_STATE_FILE" 2>/dev/null || true
# additionalContext (not systemMessage) so the nudge reaches the model β€” see
# print_toolsearch_message above.
OUTPUT=$(jq -n \
'{"systemMessage": "MEMORY REMINDER: It'\''s been over 15 minutes since your last save. If you'\''ve made decisions, discoveries, or completed significant work, call mem_save now."}')
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: "MEMORY REMINDER: It'\''s been over 15 minutes since your last save. If you'\''ve made decisions, discoveries, or completed significant work, call mem_save now."}}')
fi
fi

Expand Down
Loading