fix(hooks): NL trigger misfires + one-shot independent modes

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
This commit is contained in:
AmirF194
2026-07-01 23:47:59 -06:00
parent 25d22f864a
commit 2fb7c91183
2 changed files with 274 additions and 18 deletions
+78 -18
View File
@@ -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-<name> — 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: {
+196
View File
@@ -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()