@@ -5294,6 +5294,53 @@ static bool cbm_install_subagent_reminder_script(const char *home, const char *b
52945294 return cbm_write_owned_hook_script_with_legacy(script_path, script, legacy, 1U);
52955295}
52965296
5297+ /* #1387 dry-run predicate: would writing this hook script succeed, or would
5298+ * the owned-document migration refuse it because the file on disk is not ours?
5299+ * Read-only — it must never touch the filesystem, since a dry run promises
5300+ * exactly that. An unreadable/unsafe state counts as "would not succeed": the
5301+ * preview should warn rather than promise. */
5302+ static bool cbm_hook_script_write_would_succeed(const char *home, const char *binary_path,
5303+ const char *script_name) {
5304+ if (!home || !binary_path || !script_name) {
5305+ return false;
5306+ }
5307+ char config_dir[CLI_BUF_1K];
5308+ cbm_claude_config_dir(home, config_dir, sizeof(config_dir));
5309+ if (!config_dir[0]) {
5310+ return false;
5311+ }
5312+ char script_path[CLI_BUF_1K];
5313+ int written =
5314+ snprintf(script_path, sizeof(script_path), "%s/hooks/%s", config_dir, script_name);
5315+ if (written <= 0 || (size_t)written >= sizeof(script_path)) {
5316+ return false;
5317+ }
5318+ const char *prefix = cmm_gate_script_prefix;
5319+ if (strcmp(script_name, CMM_SESSION_REMINDER_SCRIPT) == 0) {
5320+ prefix = cmm_session_script_prefix;
5321+ } else if (strcmp(script_name, CMM_SUBAGENT_REMINDER_SCRIPT) == 0) {
5322+ prefix = cmm_subagent_script_prefix;
5323+ }
5324+ char script[CLI_BUF_8K];
5325+ if (cbm_build_current_hook_script(prefix, binary_path, script, sizeof(script)) != CLI_OK) {
5326+ return false;
5327+ }
5328+ /* Released shapes are accepted by the real write, so they must be accepted
5329+ * here too or the preview would warn about a script that upgrades fine. */
5330+ char released[CLI_BUF_8K];
5331+ const char *candidates[2];
5332+ size_t candidate_count = 0U;
5333+ if (strcmp(script_name, CMM_HOOK_GATE_SCRIPT) == 0 &&
5334+ cbm_build_released_gate_script(binary_path, released, sizeof(released)) == CLI_OK) {
5335+ candidates[candidate_count++] = released;
5336+ } else if (strcmp(script_name, CMM_SESSION_REMINDER_SCRIPT) == 0) {
5337+ candidates[candidate_count++] = cmm_released_session_script;
5338+ } else if (strcmp(script_name, CMM_SUBAGENT_REMINDER_SCRIPT) == 0) {
5339+ candidates[candidate_count++] = cmm_released_subagent_script;
5340+ }
5341+ return cbm_text_owned_document_status(script_path, script, candidates, candidate_count) == 0;
5342+ }
5343+
52975344int cbm_upsert_claude_subagent_hooks(const char *settings_path) {
52985345 char command[CLI_BUF_8K];
52995346 char previous_command[CLI_BUF_8K];
@@ -7195,6 +7242,18 @@ static void install_claude_code_config(const char *home, const char *binary_path
71957242 bool gate_ok = dry_run;
71967243 bool session_ok = dry_run;
71977244 bool subagent_ok = dry_run;
7245+ if (dry_run) {
7246+ /* #1387 (second half): the dry run claimed EVERY hook group as
7247+ * installable because these flags were simply `true`. When the on-disk
7248+ * script is not ours the real install refuses to rewrite it, so the
7249+ * preview promised what the run could not deliver and the reporter had
7250+ * no way to see the loss coming. Predict each refusal read-only. */
7251+ gate_ok = cbm_hook_script_write_would_succeed(home, binary_path, CMM_HOOK_GATE_SCRIPT);
7252+ session_ok =
7253+ cbm_hook_script_write_would_succeed(home, binary_path, CMM_SESSION_REMINDER_SCRIPT);
7254+ subagent_ok =
7255+ cbm_hook_script_write_would_succeed(home, binary_path, CMM_SUBAGENT_REMINDER_SCRIPT);
7256+ }
71987257 if (!dry_run) {
71997258 char hook_path[CLI_BUF_1K];
72007259 gate_ok = cbm_install_hook_gate_script(home, binary_path);
@@ -7241,6 +7300,22 @@ static void install_claude_code_config(const char *home, const char *binary_path
72417300 if (subagent_ok) {
72427301 printf(" hooks: SubagentStart (MCP usage reminder for subagents)\n");
72437302 }
7303+ if (dry_run) {
7304+ /* Name every script the real run would refuse. Silence is what made
7305+ * #1387 invisible in advance: the group simply went unmentioned, which
7306+ * reads as "nothing to do" rather than "this will be skipped". */
7307+ static const char *const preview_scripts[] = {
7308+ CMM_HOOK_GATE_SCRIPT, CMM_SESSION_REMINDER_SCRIPT, CMM_SUBAGENT_REMINDER_SCRIPT};
7309+ const bool preview_ok[] = {gate_ok, session_ok, subagent_ok};
7310+ for (size_t i = 0U; i < sizeof(preview_scripts) / sizeof(preview_scripts[0]); i++) {
7311+ if (!preview_ok[i]) {
7312+ printf(" hooks: %s/hooks/%s would be skipped — the file there is not ours "
7313+ "(modified, or written by another install), so the rewrite is refused "
7314+ "and existing hook entries are left untouched\n",
7315+ config_dir, preview_scripts[i]);
7316+ }
7317+ }
7318+ }
72447319
72457320 /* Migration nudge: when CLAUDE_CONFIG_DIR is set and a legacy ~/.claude tree
72467321 * still exists, mention it so users can clean up stale artifacts. */
0 commit comments