mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
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:
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user