From 402617555cd4cb56844930eb759a421922ef409c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 25 Oct 2026 21:17:24 +0100 Subject: [PATCH 1/2] feat(cli): note when a Claude Code session keeps searching without the graph The search hook gives each Grep, Glob or Bash search the matching graph symbols, but a session can keep searching text for questions the graph answers in one call (#509). Once a session has run three searches with no codebase-memory-mcp tool call, the next augmentation now adds one factual line saying so. The next note comes after six searches, then twelve, and any graph tool call resets the count. - State: a per-session counter in the process that runs hook requests (the daemon), keyed by the payload's session_id, in a bounded 64-slot table that evicts the least recently used session, behind a spin lock. Only searches that reach the graph lookup count. A search without an augmentation still counts, and the note waits for the next augmentation. - Reset: Claude's managed PostToolUse entry now matches "Read|mcp__.*codebase-memory-mcp__.*", covering plain and plugin server names. The previous Read-only entry, as installs up to v0.12 wrote it, is recognized as owned, so install replaces it in place and uninstall removes either. - Scope: Claude Code only. Codex sends the same event payload with a turn_id but has no reset hook, so its sessions are not tracked, and payloads without a session_id are not tracked either. - Cost: a graph tool call now runs one hook round-trip to the daemon for the reset, about 130 ms on a local build. Refs #509 Signed-off-by: Martin Vogel --- README.md | 7 +- src/cli/cli.c | 14 ++- src/cli/hook_augment.c | 145 +++++++++++++++++++++++- tests/test_cli.c | 248 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 401 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8fd5c68a9..c9b4a46e5 100644 --- a/README.md +++ b/README.md @@ -694,7 +694,7 @@ overwrite user-modified agents. | Agent | Activation | MCP config | Durable context / augmentation | |-------|------------|------------|--------------------------------| -| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, and post-`Read` coverage | +| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, post-`Read` coverage, and a drift note after repeated searches without a graph call | | Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart`, `PreToolUse` graph lookup for `Bash` searches | | Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` | | Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill | @@ -755,7 +755,10 @@ the requested tool call. Codex CLI's `PreToolUse` on `Bash` gives its shell searches the same context. GitHub Copilot CLI and VS Code's Copilot runtime get it after the search: their `preToolUse` can only decide permissions, so a `PostToolUse` entry for `Bash`, `Grep` and `Glob` appends the graph symbols to -the tool result. +the tool result. In Claude Code, once a session has run three searches with no +codebase-memory-mcp tool call, the next augmentation adds one line saying so; +the next note comes after six searches, then twelve. Claude's `PostToolUse` +entry also matches the graph tools, and a graph tool call resets the count. Claude Code, Codex CLI, Qwen Code, GitHub Copilot CLI, and VS Code's Copilot runtime receive paired session/subagent context where the vendor exposes a diff --git a/src/cli/cli.c b/src/cli/cli.c index ba4ae520a..95beda3f4 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -4661,7 +4661,10 @@ static int cbm_remove_grok_mcp_owned(const char *binary_path, const char *config /* Search augmentation runs before Grep/Glob/Bash; exact coverage context runs after * Read. Both adapters are context-only and fail open. */ #define CMM_HOOK_SEARCH_MATCHER "Grep|Glob|Bash" -#define CMM_HOOK_READ_MATCHER "Read" +/* The PostToolUse entry also matches the graph tools (mcp____, + * or mcp__plugin____), whose calls reset the drift + * reminder's search count (#509). */ +#define CMM_HOOK_READ_MATCHER "Read|mcp__.*codebase-memory-mcp__.*" /* Basename only; the full command path is resolved at install time via * cbm_resolve_hook_command so $CLAUDE_CONFIG_DIR is honored. */ #ifdef _WIN32 @@ -4682,6 +4685,11 @@ static const char *const cmm_claude_exec_args[] = {"hook-augment"}; /* Old matcher values from previous versions — recognized during upgrade so * upsert/remove can clean them up before inserting the current matcher. * Per-agent lists (no shared global): each caller passes its own. */ +/* Installs up to v0.12 matched only Read in PostToolUse. */ +static const char *const cmm_claude_old_read_matchers[] = { + "Read", + NULL, +}; static const char *const cmm_claude_old_matchers[] = { "Grep|Glob|Read|Search", "Grep|Glob|Read", @@ -5459,7 +5467,7 @@ static int cbm_upsert_claude_hooks_with_binary(const char *settings_path, const if (cbm_remove_previous_claude_exec_hook(settings_path, "PreToolUse", CMM_HOOK_SEARCH_MATCHER, cmm_claude_old_matchers, binary_path) != CLI_OK || cbm_remove_previous_claude_exec_hook(settings_path, "PostToolUse", CMM_HOOK_READ_MATCHER, - NULL, binary_path) != CLI_OK) { + cmm_claude_old_read_matchers, binary_path) != CLI_OK) { return CLI_ERR; } int search_result = upsert_hooks_json((hooks_upsert_args_t){ @@ -5478,6 +5486,7 @@ static int cbm_upsert_claude_hooks_with_binary(const char *settings_path, const .settings_path = settings_path, .hook_event = "PostToolUse", .matcher_str = CMM_HOOK_READ_MATCHER, + .old_matchers = cmm_claude_old_read_matchers, .command_str = command, .command_args = command_args, .command_arg_count = command_arg_count, @@ -5532,6 +5541,7 @@ static int cbm_remove_claude_hooks_with_binary(const char *settings_path, const .settings_path = settings_path, .hook_event = "PostToolUse", .matcher_str = CMM_HOOK_READ_MATCHER, + .old_matchers = cmm_claude_old_read_matchers, .old_commands = old_commands, .match_command_exact = command, .match_args = command_args, diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 822846d4a..c161f1dbf 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -26,6 +26,8 @@ #include "yyjson/yyjson.h" #include +#include +#include #include #include #include @@ -1414,6 +1416,129 @@ static void ha_tool_call_free(ha_tool_call_t *call) { call->input = NULL; } +/* Drift reminder (#509). A Claude Code session that keeps searching with Grep, + * Glob or Bash and makes no codebase-memory-mcp tool call gets one factual line + * on its next augmentation once HA_DRIFT_FIRST searches have run; the threshold + * then doubles (3, 6, 12, ...), and a graph tool call resets the session. The + * managed PostToolUse hook matches the graph tools for that reset. The state + * lives in the process that runs hook requests, which is the daemon, keyed by + * the payload's session_id in a bounded table that evicts the least recently + * used session. Codex sends the same event payload, with a turn_id, but has no + * reset hook, so its sessions are not tracked. */ +enum { + HA_DRIFT_SESSIONS = 64, + HA_DRIFT_FIRST = 3, + HA_DRIFT_SESSION_MAX = 128, +}; + +typedef struct { + char session[HA_DRIFT_SESSION_MAX + 1]; + unsigned searches; + unsigned threshold; + uint64_t used; +} ha_drift_slot_t; + +static ha_drift_slot_t g_ha_drift[HA_DRIFT_SESSIONS]; +static uint64_t g_ha_drift_clock; +static atomic_flag g_ha_drift_spin = ATOMIC_FLAG_INIT; + +static void ha_drift_lock(void) { + while (atomic_flag_test_and_set_explicit(&g_ha_drift_spin, memory_order_acquire)) {} +} + +static void ha_drift_unlock(void) { + atomic_flag_clear_explicit(&g_ha_drift_spin, memory_order_release); +} + +static const char *ha_drift_session(yyjson_val *root, ha_lifecycle_dialect_t dialect) { + if (dialect != HA_DIALECT_EVENT || !root || yyjson_obj_get(root, "turn_id")) { + return NULL; + } + const char *session = ha_obj_str(root, "session_id"); + size_t length = session ? strlen(session) : 0U; + if (length == 0U || length > HA_DRIFT_SESSION_MAX) { + return NULL; + } + for (size_t i = 0U; i < length; i++) { + unsigned char ch = (unsigned char)session[i]; + if (!isalnum(ch) && ch != '-' && ch != '_' && ch != '.') { + return NULL; + } + } + return session; +} + +/* Claude names MCP tools mcp____, and a plugin's servers + * mcp__plugin____. */ +static bool ha_is_graph_tool(const char *tool) { + return tool && strncmp(tool, "mcp__", 5) == 0 && strstr(tool, "codebase-memory-mcp__"); +} + +/* The caller holds the lock. An unknown session takes an empty slot, or else + * the least recently used one. */ +static ha_drift_slot_t *ha_drift_find(const char *session, bool create) { + ha_drift_slot_t *victim = NULL; + for (size_t i = 0U; i < HA_DRIFT_SESSIONS; i++) { + ha_drift_slot_t *slot = &g_ha_drift[i]; + if (slot->session[0] && strcmp(slot->session, session) == 0) { + slot->used = ++g_ha_drift_clock; + return slot; + } + if (!victim || (victim->session[0] && (!slot->session[0] || slot->used < victim->used))) { + victim = slot; + } + } + if (!create) { + return NULL; + } + snprintf(victim->session, sizeof(victim->session), "%s", session); + victim->searches = 0U; + victim->threshold = HA_DRIFT_FIRST; + victim->used = ++g_ha_drift_clock; + return victim; +} + +static void ha_drift_reset(const char *session) { + ha_drift_lock(); + ha_drift_slot_t *slot = ha_drift_find(session, false); + if (slot) { + memset(slot, 0, sizeof(*slot)); + } + ha_drift_unlock(); +} + +/* Counts one search. The reminder is due when the count has reached the + * threshold and this search has an augmentation to carry it; delivering it + * raises the threshold past the count by doubling. */ +static bool ha_drift_note_search(const char *session, bool augmented, unsigned *searches) { + ha_drift_lock(); + ha_drift_slot_t *slot = ha_drift_find(session, true); + if (slot->searches < UINT_MAX) { + slot->searches++; + } + bool due = augmented && slot->searches >= slot->threshold; + while (due && slot->threshold <= slot->searches && slot->threshold <= UINT_MAX / 2U) { + slot->threshold *= 2U; + } + *searches = slot->searches; + ha_drift_unlock(); + return due; +} + +/* Writes the context followed by the drift line into out. Returns false when + * they do not fit, and the caller then sends the context alone. The context is + * at most 4096 bytes plus the follow-up line, so HA_DRIFT_CONTEXT_CAP fits it. */ +enum { HA_DRIFT_CONTEXT_CAP = 5120 }; + +static bool ha_drift_context(char *out, size_t out_size, const char *context, unsigned searches) { + int written = snprintf(out, out_size, + "%s\n[codebase-memory] This session has run %u searches since its last " + "codebase-memory-mcp tool call. Questions about callers, callees, call " + "paths and definitions are one graph call each.", + context, searches); + return written > 0 && (size_t)written < out_size; +} + static bool ha_tool_event_supported(ha_lifecycle_dialect_t dialect, const char *event, const char *tool, bool *coverage) { if (coverage) { @@ -1752,6 +1877,13 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const cha ha_tool_call_read(root, forced_event, dialect, &call); const char *event = call.event; const char *tool = call.tool; + const char *session = ha_drift_session(root, dialect); + if (session && event && strcmp(event, "PostToolUse") == 0 && ha_is_graph_tool(tool)) { + ha_drift_reset(session); + ha_tool_call_free(&call); + yyjson_doc_free(doc); + return NULL; + } bool coverage = false; if (!ha_tool_event_supported(dialect, event, tool, &coverage)) { ha_tool_call_free(&call); @@ -1809,10 +1941,17 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const cha } char *ctx = ha_resolve_and_query(srv, cwd, token); + unsigned searches = 0U; + char drift_context[HA_DRIFT_CONTEXT_CAP]; + const char *text = ctx; + if (session && ha_drift_note_search(session, ctx != NULL, &searches) && + ha_drift_context(drift_context, sizeof(drift_context), ctx, searches)) { + text = drift_context; + } char *output = NULL; - if (ctx) { - output = dialect == HA_DIALECT_COPILOT ? ha_build_copilot_json(ctx) - : ha_build_event_json(event, ctx); + if (text) { + output = dialect == HA_DIALECT_COPILOT ? ha_build_copilot_json(text) + : ha_build_event_json(event, text); } free(ctx); yyjson_doc_free(doc); diff --git a/tests/test_cli.c b/tests/test_cli.c index 9853761cb..e3383be78 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -586,6 +586,10 @@ static size_t test_count_exec_hook(yyjson_val *root, const char *event_name, return matches; } +/* The Claude PostToolUse matcher: Read coverage plus the graph tools, whose + * calls reset the drift reminder. */ +#define TEST_CLAUDE_POST_MATCHER "Read|mcp__.*codebase-memory-mcp__.*" + #ifdef _WIN32 static bool test_append_command_hook(yyjson_mut_doc *doc, yyjson_mut_val *event_entries, const char *matcher, const char *command) { @@ -13559,7 +13563,8 @@ TEST(cli_claude_hook_commands_use_exec_form_with_custom_config_dir) { static const char *const matchers[] = {"startup", "resume", "clear", "compact"}; size_t owned = test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary, "hook-augment") + - test_count_exec_hook(root, "PostToolUse", "Read", binary, "hook-augment") + + test_count_exec_hook(root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, binary, + "hook-augment") + test_count_exec_hook(root, "SubagentStart", "*", binary, "hook-augment"); for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) { owned += test_count_exec_hook(root, "SessionStart", matchers[i], binary, "hook-augment"); @@ -14100,6 +14105,134 @@ TEST(cli_hook_augment_copilot_posttooluse_search) { PASS(); } +/* One Claude Code Grep search for the drift tests; returns whether it was + * augmented and sets *reminded when the drift line came with it. */ +static bool test_drift_search(cbm_mcp_server_t *srv, const char *session, const char *extra, + const char *pattern, bool *reminded) { + char input[640]; + snprintf(input, sizeof(input), + "{\"hook_event_name\":\"PreToolUse\",\"session_id\":\"%s\",%s\"tool_name\":\"Grep\"," + "\"cwd\":\"/tmp/hookproj\",\"tool_input\":{\"pattern\":\"%s\"}}", + session, extra, pattern); + char *output = cbm_hook_augment_process(srv, input); + *reminded = output && strstr(output, "searches since its last codebase-memory-mcp tool call"); + bool augmented = output != NULL; + free(output); + return augmented; +} + +static bool test_drift_graph_call(cbm_mcp_server_t *srv, const char *session, const char *tool) { + char input[512]; + snprintf(input, sizeof(input), + "{\"hook_event_name\":\"PostToolUse\",\"session_id\":\"%s\",\"tool_name\":\"%s\"," + "\"cwd\":\"/tmp/hookproj\",\"tool_input\":{}}", + session, tool); + char *output = cbm_hook_augment_process(srv, input); + bool silent = output == NULL; + free(output); + return silent; +} + +/* Drift reminder (#509): a Claude Code session that keeps searching without a + * graph call gets one factual line on its 3rd search, then its 6th and 12th. + * A graph tool call, by its plain or plugin name, resets the count. A search + * without an augmentation still counts and the line waits for the next one. + * Sessions are independent; Codex payloads (turn_id) and payloads without a + * session_id are never tracked. */ +TEST(cli_hook_augment_drift_reminder_counts_searches) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + char *project = cbm_project_name_from_path("/tmp/hookproj"); + ASSERT_NOT_NULL(project); + cbm_mcp_server_set_project(srv, project); + cbm_store_upsert_project(st, project, "/tmp/hookproj"); + char qualified_name[256]; + snprintf(qualified_name, sizeof(qualified_name), "%s.mod.someIndexedSymbol", project); + cbm_node_t node = {.project = project, + .label = "Function", + .name = "someIndexedSymbol", + .qualified_name = qualified_name, + .file_path = "mod.py", + .start_line = 1, + .end_line = 4}; + ASSERT_GT(cbm_store_upsert_node(st, &node), 0); + const char *hit = "someIndexedSymbol"; + bool ok = true; + bool reminded = false; + char diag[256] = ""; + + /* Reminders at 3, 6 and 12 searches; session b interleaves. */ + for (int i = 1; ok && i <= 12; i++) { + bool b_reminded = false; + if (i <= 3) { + ok = test_drift_search(srv, "drift-test-b", "", hit, &b_reminded) && + b_reminded == (i == 3); + } + ok = ok && test_drift_search(srv, "drift-test-a", "", hit, &reminded) && + reminded == (i == 3 || i == 6 || i == 12); + if (!ok) + snprintf(diag, sizeof(diag), "session a search %d reminded=%d", i, reminded); + } + /* A graph call resets; the plugin tool name resets too. */ + for (int round = 0; ok && round < 2; round++) { + const char *tool = round == 0 ? "mcp__codebase-memory-mcp__trace_path" + : "mcp__plugin_codebase-memory-mcp_codebase-memory-mcp__" + "search_graph"; + ok = test_drift_graph_call(srv, "drift-test-a", tool); + for (int i = 1; ok && i <= 3; i++) { + ok = test_drift_search(srv, "drift-test-a", "", hit, &reminded) && reminded == (i == 3); + } + if (!ok) + snprintf(diag, sizeof(diag), "reset round %d", round); + } + /* Another server's tool is no graph call: after a reset and one search it + * leaves the count alone, so the 3rd search still carries the line. */ + ok = ok && + test_drift_graph_call(srv, "drift-test-a", "mcp__codebase-memory-mcp__search_graph") && + test_drift_search(srv, "drift-test-a", "", hit, &reminded) && !reminded && + test_drift_graph_call(srv, "drift-test-a", "mcp__other__search") && + test_drift_search(srv, "drift-test-a", "", hit, &reminded) && !reminded && + test_drift_search(srv, "drift-test-a", "", hit, &reminded) && reminded; + if (!ok && !diag[0]) + snprintf(diag, sizeof(diag), "foreign tool reset the count"); + /* The 3rd search finds nothing, so the 4th carries the line; the next + * threshold is 6. */ + bool expect_c[] = {false, false, false, true, false, true}; + for (int i = 0; ok && i < 6; i++) { + bool augmented = test_drift_search(srv, "drift-test-c", "", + i == 2 ? "zzzNotIndexedAnywhere" : hit, &reminded); + ok = augmented == (i != 2) && reminded == expect_c[i]; + if (!ok) + snprintf(diag, sizeof(diag), "session c search %d augmented=%d reminded=%d", i + 1, + augmented, reminded); + } + /* Untracked: Codex, and no session_id. */ + for (int i = 1; ok && i <= 6; i++) { + ok = + test_drift_search(srv, "drift-test-codex", "\"turn_id\":\"turn-1\",", hit, &reminded) && + !reminded; + char input[512]; + snprintf(input, sizeof(input), + "{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Grep\"," + "\"cwd\":\"/tmp/hookproj\",\"tool_input\":{\"pattern\":\"%s\"}}", + hit); + char *output = cbm_hook_augment_process(srv, input); + ok = ok && output && !strstr(output, "since its last codebase-memory-mcp tool call"); + free(output); + if (!ok) + snprintf(diag, sizeof(diag), "untracked search %d", i); + } + cbm_mcp_server_free(srv); + free(project); + if (!ok) { + fprintf(stderr, "drift diag: %s\n", diag); + FAIL("drift reminder must fire at 3, 6, 12 searches and reset on a graph call"); + } + PASS(); +} + /* The hook-client fast path answers Copilot's PostToolUse before identity * hashing when the augmenter could never respond: a shell command that is not * a search, or a tool the augmenter does not read (VS Code's local hook parser @@ -14761,7 +14894,8 @@ TEST(cli_upgrade_migrates_released_claude_hook_scripts) { size_t registered = test_count_exec_hook(settings_root, "PreToolUse", "Grep|Glob|Bash", binary, "hook-augment") + - test_count_exec_hook(settings_root, "PostToolUse", "Read", binary, "hook-augment") + + test_count_exec_hook(settings_root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, binary, + "hook-augment") + test_count_exec_hook(settings_root, "SubagentStart", "*", binary, "hook-augment"); for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) { registered += test_count_exec_hook(settings_root, "SessionStart", matchers[i], binary, @@ -16006,7 +16140,7 @@ TEST(cli_upsert_claude_hook_fresh) { ASSERT(strstr(data, "PreToolUse") != NULL); ASSERT(strstr(data, "PostToolUse") != NULL); ASSERT(strstr(data, "\"Grep|Glob|Bash\"") != NULL); - ASSERT(strstr(data, "\"Read\"") != NULL); + ASSERT(strstr(data, "\"" TEST_CLAUDE_POST_MATCHER "\"") != NULL); ASSERT(strstr(data, "\"Grep|Glob|Read\"") == NULL); ASSERT_EQ(test_count_substring(data, "cbm-code-discovery-gate"), 2U); @@ -16478,7 +16612,8 @@ TEST(cli_claude_hooks_use_exec_form_across_shells_issue1733) { size_t owned = test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary_path, "hook-augment") + - test_count_exec_hook(root, "PostToolUse", "Read", binary_path, "hook-augment") + + test_count_exec_hook(root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, binary_path, + "hook-augment") + test_count_exec_hook(root, "SubagentStart", "*", binary_path, "hook-augment"); for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) { owned += test_count_exec_hook(root, "SessionStart", matchers[i], binary_path, @@ -16542,7 +16677,8 @@ TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733) { size_t before_owned = test_count_exec_hook(installed_root, "PreToolUse", "Grep|Glob|Bash", binary_path, "hook-augment") + - test_count_exec_hook(installed_root, "PostToolUse", "Read", binary_path, "hook-augment") + + test_count_exec_hook(installed_root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, binary_path, + "hook-augment") + test_count_exec_hook(installed_root, "SubagentStart", "*", binary_path, "hook-augment"); for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) { before_owned += test_count_exec_hook(installed_root, "SessionStart", matchers[i], @@ -16576,6 +16712,104 @@ TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733) { PASS(); } +/* Rewrite the current Claude PostToolUse matcher back to the previous "Read". */ +static bool test_claude_settings_to_read_matcher(const char *settings_path) { + char *text = read_test_file_alloc(settings_path); + const char *found = text ? strstr(text, TEST_CLAUDE_POST_MATCHER) : NULL; + if (!found) { + free(text); + return false; + } + size_t prefix = (size_t)(found - text); + size_t total = strlen(text) - strlen(TEST_CLAUDE_POST_MATCHER) + strlen("Read") + 1U; + char *rewritten = malloc(total); + if (!rewritten) { + free(text); + return false; + } + snprintf(rewritten, total, "%.*sRead%s", (int)prefix, text, + found + strlen(TEST_CLAUDE_POST_MATCHER)); + write_test_file(settings_path, rewritten); + free(rewritten); + free(text); + return true; +} + +/* The Claude PostToolUse hook also matches the graph tools, whose calls reset + * the drift reminder (#509). An install that finds the previous Read-only + * entry replaces it with one current entry, and uninstall removes the + * previous one too. */ +TEST(cli_claude_post_hook_matcher_migrates_from_read) { + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_config = save_test_env("CLAUDE_CONFIG_DIR"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-read-matcher-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char config_dir[512]; + char settings_path[640]; + char install_dir[512]; + char binary_path[640]; + snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir); + snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir); + snprintf(install_dir, sizeof(install_dir), "%s/bin", tmpdir); +#ifdef _WIN32 + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp.exe", install_dir); +#else + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp", install_dir); +#endif + test_mkdirp(config_dir); + test_mkdirp(install_dir); + test_mark_claude_code_installed(tmpdir); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_unsetenv("CLAUDE_CONFIG_DIR"); + + size_t counts[4] = {0U, 0U, 0U, 0U}; + int install_rc[2] = {-1, -1}; + for (int round = 0; round < 2; round++) { + install_rc[round] = cbm_install_agent_configs(tmpdir, binary_path, false, false); + char *settings = read_test_file_alloc(settings_path); + yyjson_doc *doc = settings ? yyjson_read(settings, strlen(settings), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + counts[round * 2] = test_count_exec_hook(root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, + binary_path, "hook-augment"); + counts[round * 2 + 1] = + test_count_exec_hook(root, "PostToolUse", "Read", binary_path, "hook-augment"); + yyjson_doc_free(doc); + free(settings); + if (round == 0 && !test_claude_settings_to_read_matcher(settings_path)) + break; + } + bool rewritten = test_claude_settings_to_read_matcher(settings_path); + char dir_arg[640]; + snprintf(dir_arg, sizeof(dir_arg), "--dir=%s", install_dir); + char *argv[] = {"uninstall", "--yes", dir_arg}; + int uninstall_rc = cli_test_cmd_uninstall(3, argv); + char *uninstalled = read_test_file_alloc(settings_path); + size_t after_owned = test_count_substring(uninstalled ? uninstalled : "", "\"hook-augment\""); + free(uninstalled); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CLAUDE_CONFIG_DIR", saved_config); + test_rmdir_r(tmpdir); + if (install_rc[0] != 0 || install_rc[1] != 0 || counts[0] != 1U || counts[1] != 0U || + counts[2] != 1U || counts[3] != 0U || !rewritten || uninstall_rc != 0 || + after_owned != 0U) { + fprintf(stderr, + "Claude matcher diag install=%d,%d current=%zu,%zu read=%zu,%zu rewritten=%d " + "uninstall=%d after=%zu\n", + install_rc[0], install_rc[1], counts[0], counts[2], counts[1], counts[3], rewritten, + uninstall_rc, after_owned); + FAIL("Claude install must migrate the Read-only PostToolUse hook, and uninstall must " + "remove it"); + } + PASS(); +} + /* issue #618: hook-augment was a structural no-op on Windows because its path * guards required POSIX-style '/'-prefixed absolute paths, so a drive-letter * cwd (C:/repo) was rejected before any search_graph query. The predicate must @@ -16941,7 +17175,7 @@ TEST(cli_upsert_claude_hook_existing) { ASSERT_NOT_NULL(data); ASSERT(strstr(data, "\"Grep|Glob|Bash\"") != NULL); ASSERT(strstr(data, "PostToolUse") != NULL); - ASSERT(strstr(data, "\"Read\"") != NULL); + ASSERT(strstr(data, "\"" TEST_CLAUDE_POST_MATCHER "\"") != NULL); /* Existing hook preserved */ ASSERT(strstr(data, "Bash") != NULL); ASSERT(strstr(data, "firewall") != NULL); @@ -19084,6 +19318,7 @@ SUITE(cli) { RUN_TEST(cli_hook_augment_codex_pretooluse_payload); RUN_TEST(cli_hook_augment_copilot_posttooluse_search); RUN_TEST(cli_hook_augment_copilot_posttooluse_noop_gate); + RUN_TEST(cli_hook_augment_drift_reminder_counts_searches); RUN_TEST(cli_hook_augment_bash_pattern_extractor); RUN_TEST(cli_hook_augment_coverage_requests_machine_json); RUN_TEST(cli_hook_augment_lifecycle_output_contract); @@ -19169,6 +19404,7 @@ SUITE(cli) { RUN_TEST(cli_windows_claude_hook_command_is_shell_portable); RUN_TEST(cli_claude_hooks_use_exec_form_across_shells_issue1733); RUN_TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733); + RUN_TEST(cli_claude_post_hook_matcher_migrates_from_read); RUN_TEST(cli_hook_augment_path_is_abs); RUN_TEST(cli_hook_augment_deadline_breadcrumb_issue858); RUN_TEST(cli_hook_augment_log_long_home_creates_no_cut_path); From 8fd93a2e08bb10aa8d9943e0043e1a6f03439958 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 19:32:04 +0100 Subject: [PATCH 2/2] test(smoke): pin the Claude PostToolUse matcher that covers the graph tools Smoke check 8d required Claude's PostToolUse matcher to be exactly Read. The drift note widens it on purpose to Read|mcp__.*codebase-memory-mcp__.*, so a graph tool call can reset the search count, and the macOS PR smoke failed on it. 8d now requires exactly the new matcher, and besides a Search matcher it also refuses a catch-all ("*" or empty) matcher, as its comment promised. Checked against real installs: the previous binary's Read-only settings fail 8d, the new install passes, and the new install plus a catch-all PostToolUse entry fails. Refs #509 Signed-off-by: Martin Vogel --- scripts/smoke-test.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index c1a3f3ec7..2a894ca55 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1697,7 +1697,8 @@ echo "OK 8c-i: Claude exact-tool graph subagent" # 8d: Claude Code hooks keep search augmentation and read-coverage reporting # separate: PreToolUse matches exactly Grep|Glob|Bash, while PostToolUse matches -# exactly Read. Neither hook may grow a Search or catch-all matcher. +# exactly Read plus the graph tools, whose calls reset the drift note. Neither +# hook may grow a Search or catch-all matcher. if ! cat "$FAKE_HOME/.claude/settings.json" 2>/dev/null | python3 -c " import json, sys d = json.load(sys.stdin) @@ -1705,14 +1706,15 @@ all_hooks = d.get('hooks', {}) pre = all_hooks.get('PreToolUse', []) post = all_hooks.get('PostToolUse', []) ok = (any(h.get('matcher') == 'Grep|Glob|Bash' for h in pre) and - any(h.get('matcher') == 'Read' for h in post)) -bad = any('Search' in str(h.get('matcher', '')) for h in pre + post) + any(h.get('matcher') == 'Read|mcp__.*codebase-memory-mcp__.*' for h in post)) +bad = any('Search' in str(h.get('matcher', '')) or h.get('matcher', '') in ('', '*') + for h in pre + post) sys.exit(0 if (ok and not bad) else 1) " 2>/dev/null; then echo "FAIL 8d: Claude search/read hook matchers are not exact" exit 1 fi -echo "OK 8d: Claude Code PreToolUse Grep|Glob|Bash + PostToolUse Read" +echo "OK 8d: Claude Code PreToolUse Grep|Glob|Bash + PostToolUse Read and graph tools" # 8e: Claude Code shim script — must be non-blocking augmenter, not a gate. # #929: Windows installs a .cmd script (extensionless bash shims triggered the