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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-27 19:04:47 +01:00
1 parent 129443a0b7
commit 402617555c
4 files changed
+401 -13

No files matched your search

+5 -2
View File
@@ -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
+12 -2
View File
@@ -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__<server>__<tool>,
* or mcp__plugin_<plugin>_<server>__<tool>), 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,
+142 -3
View File
@@ -26,6 +26,8 @@
#include "yyjson/yyjson.h"
#include <ctype.h>
#include <limits.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
@@ -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__<server>__<tool>, and a plugin's servers
* mcp__plugin_<plugin>_<server>__<tool>. */
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);
+242 -6
View File
@@ -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);