From 2fb7c9118304aad803405c21de107a8ea0b6f262 Mon Sep 17 00:00:00 2001 From: AmirF194 Date: Wed, 1 Jul 2026 23:47:59 -0600 Subject: [PATCH] fix(hooks): NL trigger misfires + one-shot independent modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Natural-language matching (#598): - Deactivation computed first, word-order tolerant: 'turn caveman mode off' used to ACTIVATE caveman (and reset the level to default), 'turn caveman off' was a no-op. - 'enable caveman and stop apologizing' no longer deactivates (the old stop-guard fired on 'stop' anywhere, then the deactivation regex matched 'caveman and stop'). - Questions ('what is caveman mode?') no longer arm the mode. - 'normal mode' deactivates only as a command or with caveman context ('how do I exit vim normal mode' no longer kills the session mode). - Prompts normalized to one line so multiline input matches. - Scoped brevity ('be brief in the summary section') is a one-off instruction, not a session-wide switch. One-shot modes (#599): - /caveman-commit|-review|-compress save the displaced prose mode to .caveman-active.prev and the next ordinary prompt restores it (or deactivates if caveman wasn't active before) — SKILL.md's 'level persist until changed or session end' holds again. - Plugin-namespaced /caveman:caveman-commit and -review recognized (only compress and stats had the variant). - Deactivation clears the saved prev so nothing resurrects the mode. Fixes #598, fixes #599 --- src/hooks/caveman-mode-tracker.js | 96 ++++++++++++--- tests/test_mode_tracker.py | 196 ++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 tests/test_mode_tracker.py diff --git a/src/hooks/caveman-mode-tracker.js b/src/hooks/caveman-mode-tracker.js index 2d414eb..9fcd8b5 100644 --- a/src/hooks/caveman-mode-tracker.js +++ b/src/hooks/caveman-mode-tracker.js @@ -14,23 +14,50 @@ const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']); const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); const flagPath = path.join(claudeDir, '.caveman-active'); +// Remembers the prose mode active before a one-shot independent mode +// (/caveman-commit etc.) so the next ordinary prompt can restore it (#599). +const prevPath = path.join(claudeDir, '.caveman-active.prev'); let input = ''; process.stdin.on('data', chunk => { input += chunk; }); process.stdin.on('end', () => { try { const data = JSON.parse(input); - const prompt = (data.prompt || '').trim().toLowerCase(); + // Collapse whitespace so phrase triggers still match multiline prompts — + // every regex below sees a single-line prompt (#598). + const prompt = (data.prompt || '').trim().toLowerCase().replace(/\s+/g, ' '); - // Natural language activation (e.g. "activate caveman", "turn on caveman mode", - // "talk like caveman"). README tells users they can say these, but the hook - // only matched /caveman commands — flag file and statusline stayed out of sync. - // Also recognize brevity requests ("less tokens", "be brief/terse", "fewer - // tokens", "shorter answers") — README promises these trigger caveman too. - if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) || - /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt) || - /\b(less tokens|fewer tokens|be brief|be terse|shorter answers)\b/i.test(prompt)) { - if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) { + // Deactivation intent — computed FIRST so "turn caveman mode off" never + // falls through to the activation patterns (#598: the old contiguous + // "turn off" phrasing missed the "turn X off" word order entirely, and + // the activation regex then re-armed caveman at the default level). + const wantsOff = + /\b(stop|disable|deactivate|quit|exit|kill)\s+(the\s+)?caveman\b/.test(prompt) || + /\bcaveman(\s+mode)?\s+(off|stop|disabled?)\b/.test(prompt) || + /\bturn\s+off\s+(the\s+)?caveman\b/.test(prompt) || + // "normal mode" only as a command (prompt-initial, optionally led by a + // switch-back verb) or with caveman context — never mid-sentence for + // e.g. vim's normal mode ("how do I exit vim normal mode"). + /^(please\s+)?(go\s+|back\s+to\s+|switch\s+(back\s+)?to\s+|return\s+to\s+)?normal\s+mode\b/.test(prompt) || + (/\bnormal\s+mode\b/.test(prompt) && /\bcaveman\b/.test(prompt)); + + // Questions about caveman are not activation commands + // ("what is caveman mode?", "does caveman lite drop articles?"). + const isQuestion = + /^(what|whats|what's|how|why|when|where|who|does|do|did|is|are|can|could|would|should|tell me|explain)\b/.test(prompt); + + // Natural language activation (e.g. "activate caveman", "turn on caveman + // mode", "talk like caveman"). README tells users they can say these. + // Also brevity requests ("less tokens", "be brief/terse", "fewer tokens", + // "shorter answers") — but not when scoped to a single section + // ("be brief in the summary"), which is a one-off instruction, not a + // session-wide mode switch. + if (!wantsOff && !isQuestion) { + if (/\b(activate|enable|start|turn on|use|switch to|want|give me)\b[^.]{0,40}\bcaveman\b/.test(prompt) || + /\btalk like\b[^.]{0,40}\bcaveman\b/.test(prompt) || + /\bcaveman\s+mode\s+(on|please|now)\b/.test(prompt) || + /^caveman(\s+mode)?\s*[.!]*$/.test(prompt) || + /\b(less tokens|fewer tokens|be brief|be terse|shorter answers)\b(?!\s+(in|for|on|about|when|during|with)\b)/.test(prompt)) { const mode = getDefaultMode(); if (mode !== 'off') { safeWriteFlag(flagPath, mode); @@ -65,7 +92,11 @@ process.stdin.on('end', () => { return; } - // Match /caveman commands + // Match /caveman commands. Independent one-shot modes remember the prose + // mode active before them so the next ordinary prompt restores it (#599) + // — SKILL.md promises "Level persist until changed or session end", and a + // one-shot skill invocation should not count as "changed" forever. + let setIndependentThisTurn = false; if (prompt.startsWith('/caveman')) { const parts = prompt.split(/\s+/); const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc. @@ -73,9 +104,12 @@ process.stdin.on('end', () => { let mode = null; - if (cmd === '/caveman-commit') { + // Marketplace plugin installs surface commands namespaced as + // /caveman:caveman- — accept both forms for every skill (#599: + // only compress and stats had the namespaced variant). + if (cmd === '/caveman-commit' || cmd === '/caveman:caveman-commit') { mode = 'commit'; - } else if (cmd === '/caveman-review') { + } else if (cmd === '/caveman-review' || cmd === '/caveman:caveman-review') { mode = 'review'; } else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') { mode = 'compress'; @@ -95,17 +129,27 @@ process.stdin.on('end', () => { } if (mode && mode !== 'off') { + if (INDEPENDENT_MODES.has(mode)) { + // Save the prose mode being displaced — but never overwrite an + // already-saved one with another independent mode (/caveman-commit + // followed by /caveman-review must still restore the original). + const current = readFlag(flagPath); + if (current && !INDEPENDENT_MODES.has(current)) { + safeWriteFlag(prevPath, current); + } + setIndependentThisTurn = true; + } safeWriteFlag(flagPath, mode); } else if (mode === 'off') { try { fs.unlinkSync(flagPath); } catch (e) {} + try { fs.unlinkSync(prevPath); } catch (e) {} } } - // Detect deactivation — natural language and slash commands - if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) || - /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || - /\bnormal mode\b/i.test(prompt)) { + // Apply deactivation detected above + if (wantsOff) { try { fs.unlinkSync(flagPath); } catch (e) {} + try { fs.unlinkSync(prevPath); } catch (e) {} } // Per-turn reinforcement: emit a structured reminder when caveman is active. @@ -119,7 +163,23 @@ process.stdin.on('end', () => { // If the flag is missing, corrupted, oversized, or a symlink pointing at // something like ~/.ssh/id_rsa, readFlag returns null and we emit nothing // — never inject untrusted bytes into model context. - const activeMode = readFlag(flagPath); + let activeMode = readFlag(flagPath); + + // One-shot restore (#599): an independent mode set on a PREVIOUS prompt + // has served its turn — bring back the prose mode that was active before + // it, or deactivate if caveman wasn't active then. + if (activeMode && INDEPENDENT_MODES.has(activeMode) && !setIndependentThisTurn) { + const prev = readFlag(prevPath); + try { fs.unlinkSync(prevPath); } catch (e) {} + if (prev && !INDEPENDENT_MODES.has(prev)) { + safeWriteFlag(flagPath, prev); + activeMode = prev; + } else { + try { fs.unlinkSync(flagPath); } catch (e) {} + activeMode = null; + } + } + if (activeMode && !INDEPENDENT_MODES.has(activeMode)) { process.stdout.write(JSON.stringify({ hookSpecificOutput: { diff --git a/tests/test_mode_tracker.py b/tests/test_mode_tracker.py new file mode 100644 index 0000000..9eb63ea --- /dev/null +++ b/tests/test_mode_tracker.py @@ -0,0 +1,196 @@ +"""Tests for caveman-mode-tracker.js prompt parsing (issues #598, #599). + +Drives the UserPromptSubmit hook with real prompts over stdin against an +isolated CLAUDE_CONFIG_DIR and asserts the flag-file state afterwards. + +#598: natural-language triggers misfired — "turn caveman mode off" +ACTIVATED caveman (and clobbered the level to default), "turn caveman off" +was a no-op, questions about caveman armed it, and vim's "normal mode" +deactivated it. + +#599: one-shot independent modes (/caveman-commit etc.) permanently +overwrote the active prose level, and the plugin-namespaced +/caveman:caveman-commit|-review variants were not recognized at all. +""" + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +TRACKER = REPO_ROOT / "src" / "hooks" / "caveman-mode-tracker.js" + + +class ModeTrackerTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="caveman-tracker-") + self.claude_dir = Path(self._tmp.name) / ".claude" + self.claude_dir.mkdir(parents=True) + self.flag = self.claude_dir / ".caveman-active" + self.prev = self.claude_dir / ".caveman-active.prev" + + def tearDown(self): + self._tmp.cleanup() + + def send(self, prompt): + env = os.environ.copy() + env.pop("CAVEMAN_DEFAULT_MODE", None) + env["HOME"] = self._tmp.name + env["USERPROFILE"] = self._tmp.name + env["CLAUDE_CONFIG_DIR"] = str(self.claude_dir) + return subprocess.run( + ["node", str(TRACKER)], + cwd=REPO_ROOT, + env=env, + input=json.dumps({"prompt": prompt}), + text=True, + capture_output=True, + check=True, + ) + + def flag_value(self): + return self.flag.read_text() if self.flag.exists() else None + + # ── #598: deactivation word orders ────────────────────────────────── + + def test_turn_caveman_mode_off_deactivates(self): + # Pre-fix: this ACTIVATED caveman and downgraded ultra -> full. + self.flag.write_text("ultra") + self.send("turn caveman mode off") + self.assertIsNone(self.flag_value()) + + def test_turn_caveman_off_deactivates(self): + self.flag.write_text("full") + self.send("turn caveman off") + self.assertIsNone(self.flag_value()) + + def test_turn_off_caveman_deactivates(self): + self.flag.write_text("full") + self.send("turn off caveman") + self.assertIsNone(self.flag_value()) + + def test_stop_caveman_multiline_deactivates(self): + # Pre-fix: `.*` without the s flag never matched across lines. + self.flag.write_text("ultra") + self.send("stop\ncaveman") + self.assertIsNone(self.flag_value()) + + def test_normal_mode_command_deactivates(self): + self.flag.write_text("full") + self.send("normal mode") + self.assertIsNone(self.flag_value()) + + def test_back_to_normal_mode_deactivates(self): + self.flag.write_text("full") + self.send("back to normal mode please") + self.assertIsNone(self.flag_value()) + + def test_vim_normal_mode_does_not_deactivate(self): + self.flag.write_text("full") + self.send("how do I exit vim normal mode") + self.assertEqual(self.flag_value(), "full") + + # ── #598: activation guards ───────────────────────────────────────── + + def test_enable_caveman_with_stop_elsewhere_activates(self): + # Pre-fix: "stop" anywhere suppressed activation, then the + # deactivation regex matched "caveman and stop" and deleted the flag. + self.flag.write_text("full") + self.send("enable caveman and stop apologizing") + self.assertEqual(self.flag_value(), "full") + + def test_question_does_not_activate(self): + self.send("what is caveman mode?") + self.assertIsNone(self.flag_value()) + self.send("does caveman lite mode drop articles?") + self.assertIsNone(self.flag_value()) + + def test_scoped_brevity_does_not_activate(self): + self.send("be brief in the summary section") + self.assertIsNone(self.flag_value()) + + def test_unscoped_brevity_activates(self): + self.send("be brief") + self.assertEqual(self.flag_value(), "full") + + def test_activate_caveman_still_works(self): + self.send("activate caveman") + self.assertEqual(self.flag_value(), "full") + + def test_turn_on_caveman_mode_still_works(self): + self.send("turn on caveman mode") + self.assertEqual(self.flag_value(), "full") + + def test_talk_like_caveman_still_works(self): + self.send("talk like a caveman") + self.assertEqual(self.flag_value(), "full") + + def test_bare_caveman_mode_still_works(self): + self.send("caveman mode") + self.assertEqual(self.flag_value(), "full") + + # ── slash commands ────────────────────────────────────────────────── + + def test_slash_caveman_level_switch(self): + self.send("/caveman ultra") + self.assertEqual(self.flag_value(), "ultra") + + def test_slash_caveman_off(self): + self.flag.write_text("full") + self.send("/caveman off") + self.assertIsNone(self.flag_value()) + + # ── #599: one-shot independent modes ──────────────────────────────── + + def test_commit_restores_prior_level_on_next_prompt(self): + self.flag.write_text("ultra") + self.send("/caveman-commit") + self.assertEqual(self.flag_value(), "commit") + r = self.send("ordinary follow-up question") + self.assertEqual(self.flag_value(), "ultra") + self.assertIn("CAVEMAN MODE ACTIVE (ultra)", r.stdout) + + def test_commit_with_no_prior_mode_deactivates_after(self): + self.send("/caveman-commit") + self.assertEqual(self.flag_value(), "commit") + r = self.send("ordinary follow-up question") + self.assertIsNone(self.flag_value()) + self.assertNotIn("CAVEMAN MODE ACTIVE", r.stdout) + + def test_chained_independent_modes_keep_original_prev(self): + self.flag.write_text("wenyan-ultra") + self.send("/caveman-commit") + self.send("/caveman-review") + self.assertEqual(self.flag_value(), "review") + self.send("ordinary follow-up question") + self.assertEqual(self.flag_value(), "wenyan-ultra") + + def test_namespaced_commit_and_review_recognized(self): + # Pre-fix: only compress and stats had the /caveman:caveman- variant. + self.flag.write_text("full") + self.send("/caveman:caveman-commit") + self.assertEqual(self.flag_value(), "commit") + self.send("next prompt") # restore + self.send("/caveman:caveman-review") + self.assertEqual(self.flag_value(), "review") + + def test_no_reinforcement_during_independent_turn(self): + self.flag.write_text("full") + r = self.send("/caveman-commit") + self.assertNotIn("CAVEMAN MODE ACTIVE", r.stdout) + + def test_deactivation_clears_saved_prev(self): + self.flag.write_text("ultra") + self.send("/caveman-commit") + self.send("stop caveman") + self.assertIsNone(self.flag_value()) + self.assertFalse(self.prev.exists(), "prev file must not survive deactivation") + self.send("ordinary prompt") + self.assertIsNone(self.flag_value(), "nothing should resurrect the mode") + + +if __name__ == "__main__": + unittest.main()