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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1697,22 +1697,24 @@ 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)
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
Expand Down
14 changes: 12 additions & 2 deletions src/cli/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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){
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
145 changes: 142 additions & 3 deletions src/cli/hook_augment.c
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading