diff --git a/src/cli/cli.h b/src/cli/cli.h index 27ef4e3ec..de4939c74 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -477,6 +477,20 @@ char *cbm_hook_augment_lifecycle_json_for(const char *input, const char *forced_ void cbm_hook_augment_arm_deadline(void); char *cbm_hook_augment_read_stdin(void); +/* Why a hook client is not augmenting. The hook caller only ever sees stdout, + * so each reason that is actionable by the user must have a stdout notice + * (#1388: a build-conflicted daemon used to report on stderr alone, which is + * invisible in-session and reads as silent skips). */ +typedef enum { + CBM_HOOK_ADMISSION_DAEMON_ABSENT = 0, /* no daemon running: `daemon start` heals it */ + CBM_HOOK_ADMISSION_BUILD_CONFLICT /* daemon runs another build: needs `daemon stop` */ +} cbm_hook_admission_t; + +/* The JSON systemMessage a hook client must print on stdout for `reason`, or + * NULL when nothing should be printed. hook_dialect NULL = Claude Code, the + * only dialect that surfaces a stdout systemMessage to the user. */ +const char *cbm_hook_admission_notice(cbm_hook_admission_t reason, const char *hook_dialect); + /* Process one already-read hook payload using a caller-owned MCP session. * Returns a malloc-owned hook output JSON string, or NULL for fail-open/no * augmentation. This is the daemon entry; it never arms a process-global diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 404d71eb4..73ad1c45b 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1437,3 +1437,24 @@ int cbm_cmd_hook_augment(int argc, char **argv) { free(input); return 0; } + +const char *cbm_hook_admission_notice(cbm_hook_admission_t reason, const char *hook_dialect) { + /* Only Claude Code (the NULL dialect) consumes a bare stdout JSON object; + * emitting it into another dialect's channel would corrupt that protocol. */ + if (hook_dialect) { + return NULL; + } + switch (reason) { + case CBM_HOOK_ADMISSION_DAEMON_ABSENT: + return "{\"systemMessage\":\"codebase-memory-mcp: no CBM daemon is running, so graph " + "augmentation is currently skipped. Run `codebase-memory-mcp daemon start` (or " + "open an MCP session) to enable it.\"}"; + case CBM_HOOK_ADMISSION_BUILD_CONFLICT: + return "{\"systemMessage\":\"codebase-memory-mcp: graph augmentation is skipped: the " + "active CBM daemon runs a different build than this binary (usually an update was " + "installed while the old daemon kept running). Run `codebase-memory-mcp daemon " + "stop`, then retry - the next command starts a matching daemon.\"}"; + default: + return NULL; + } +} diff --git a/src/main.c b/src/main.c index b12ebc77e..6a03d0624 100644 --- a/src/main.c +++ b/src/main.c @@ -1483,12 +1483,9 @@ static void main_hook_report_absent_daemon(const char *hook_dialect) { (void)fprintf(stderr, "codebase-memory-mcp: no CBM daemon is running, so graph " "augmentation is skipped. Start an MCP session or run " "`codebase-memory-mcp daemon start` to enable it.\n"); - if (!hook_dialect) { - /* Claude hook output: a systemMessage is surfaced to the user. */ - (void)fputs("{\"systemMessage\":\"codebase-memory-mcp: no CBM daemon is running, so " - "graph augmentation is currently skipped. Run `codebase-memory-mcp daemon " - "start` (or open an MCP session) to enable it.\"}", - stdout); + const char *notice = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_DAEMON_ABSENT, hook_dialect); + if (notice) { + (void)fputs(notice, stdout); (void)fflush(stdout); } } @@ -1503,12 +1500,11 @@ static void main_hook_report_conflicted_daemon(const char *hook_dialect) { if (hook_dialect || !main_hook_absent_notice_due()) { return; } - (void)fputs("{\"systemMessage\":\"codebase-memory-mcp: graph augmentation is skipped: the " - "active CBM daemon runs a different build than this binary (usually an update " - "was installed while the old daemon kept running). Run `codebase-memory-mcp " - "daemon stop`, then retry - the next command starts a matching daemon.\"}", - stdout); - (void)fflush(stdout); + const char *notice = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_BUILD_CONFLICT, hook_dialect); + if (notice) { + (void)fputs(notice, stdout); + (void)fflush(stdout); + } } static int main_run_hook_frontend(cbm_daemon_runtime_client_t *client, const char *hook_event, diff --git a/tests/test_cli.c b/tests/test_cli.c index c1f9d4e82..cc0de91a2 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -4670,6 +4670,33 @@ TEST(cli_agent_reinstall_preserves_foreign_policy_entries) { PASS(); } +/* Regression for #1388: a hook client blocked by a daemon BUILD CONFLICT must + * emit a stdout systemMessage. stdout is the only channel a hook caller sees, + * so the pre-fix stderr-only reporting was indistinguishable from "no matches" + * and produced silent skips for the whole session. The absent-daemon notice + * must stay distinct: it points at `daemon start`, which cannot heal a build + * conflict. Non-Claude dialects take no bare stdout JSON at all. */ +TEST(cli_hook_conflict_emits_stdout_notice_issue1388) { + const char *conflict = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_BUILD_CONFLICT, NULL); + ASSERT_NOT_NULL(conflict); + ASSERT_NOT_NULL(strstr(conflict, "systemMessage")); + ASSERT_NOT_NULL(strstr(conflict, "different build")); + /* The actionable step: a conflicted daemon must be STOPPED, not started. */ + ASSERT_NOT_NULL(strstr(conflict, "daemon stop")); + + const char *absent = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_DAEMON_ABSENT, NULL); + ASSERT_NOT_NULL(absent); + ASSERT_NOT_NULL(strstr(absent, "daemon start")); + /* Distinct diagnoses: the conflict notice must never claim no daemon runs. */ + ASSERT_TRUE(strcmp(conflict, absent) != 0); + ASSERT_NULL(strstr(conflict, "no CBM daemon is running")); + + /* Other dialects do not consume a bare stdout JSON object. */ + ASSERT_NULL(cbm_hook_admission_notice(CBM_HOOK_ADMISSION_BUILD_CONFLICT, "codex")); + ASSERT_NULL(cbm_hook_admission_notice(CBM_HOOK_ADMISSION_DAEMON_ABSENT, "codex")); + PASS(); +} + TEST(cli_existing_agents_install_durable_child_context) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-durable-agents-XXXXXX"); @@ -11936,6 +11963,7 @@ SUITE(cli) { RUN_TEST(cli_new_agent_install_plans_use_documented_paths); RUN_TEST(cli_new_agent_configs_use_documented_schemas); RUN_TEST(cli_agent_reinstall_preserves_foreign_policy_entries); + RUN_TEST(cli_hook_conflict_emits_stdout_notice_issue1388); RUN_TEST(cli_existing_agents_install_durable_child_context); RUN_TEST(cli_durable_profiles_follow_current_vendor_paths); RUN_TEST(cli_cline_data_dir_only_redirects_data_state);