mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge pull request #2817 from DeusData/feat/drift-reminder-hook
feat(cli): note when a Claude Code session keeps searching without the graph
This commit is contained in:
5 files changed
+407
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-2
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user