diff --git a/src/hooks/caveman-activate.js b/src/hooks/caveman-activate.js index 09ae04c..99ea618 100644 --- a/src/hooks/caveman-activate.js +++ b/src/hooks/caveman-activate.js @@ -48,14 +48,32 @@ if (INDEPENDENT_MODES.has(mode)) { const modeLabel = mode === 'wenyan' ? 'wenyan-full' : mode; // Read SKILL.md — the single source of truth for caveman behavior. -// Plugin installs: __dirname = /hooks/, SKILL.md at /skills/caveman/SKILL.md -// Standalone installs: __dirname = $CLAUDE_CONFIG_DIR/hooks/, SKILL.md won't exist — falls back to hardcoded rules. +// Candidate locations, tried in order (#587/#589 — the old single '..' path +// resolved to /src/skills/, which doesn't exist, so plugin +// installs silently used the stale fallback ruleset): +// 1. $CLAUDE_PLUGIN_ROOT/skills/caveman/SKILL.md — Claude Code sets +// CLAUDE_PLUGIN_ROOT when invoking plugin hooks; authoritative when present. +// 2. ../../skills/caveman/SKILL.md — hook at /src/hooks/ +// (plugin.json layout) or a repo checkout. +// 3. ../skills/caveman/SKILL.md — standalone install with hooks at +// $CLAUDE_CONFIG_DIR/hooks/ and the skill at $CLAUDE_CONFIG_DIR/skills/caveman/. +// All misses fall through to the hardcoded fallback ruleset below. +const skillCandidates = []; +if (process.env.CLAUDE_PLUGIN_ROOT) { + skillCandidates.push(path.join(process.env.CLAUDE_PLUGIN_ROOT, 'skills', 'caveman', 'SKILL.md')); +} +skillCandidates.push( + path.join(__dirname, '..', '..', 'skills', 'caveman', 'SKILL.md'), + path.join(__dirname, '..', 'skills', 'caveman', 'SKILL.md') +); + let skillContent = ''; -try { - skillContent = fs.readFileSync( - path.join(__dirname, '..', 'skills', 'caveman', 'SKILL.md'), 'utf8' - ); -} catch (e) { /* standalone install — will use fallback below */ } +for (const candidate of skillCandidates) { + try { + skillContent = fs.readFileSync(candidate, 'utf8'); + break; + } catch (e) { /* try next candidate */ } +} let output; @@ -101,6 +119,8 @@ if (skillContent) { '## Rules\n\n' + 'Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. ' + 'Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.\n\n' + + "Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. Compress the style, not the language. Technical terms, code, API names, commands, error strings stay verbatim.\n\n" + + 'No self-reference. Never name or announce the style. No "caveman mode on" tags. Output caveman-only.\n\n' + 'Pattern: `[thing] [action] [reason]. [next step].`\n\n' + 'Not: "Sure! I\'d be happy to help you with that. The issue you\'re experiencing is likely caused by..."\n' + 'Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"\n\n' + diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 6ae80a5..4fd486d 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -1,5 +1,6 @@ import json import os +import shutil import subprocess import tempfile import unittest @@ -10,10 +11,13 @@ REPO_ROOT = Path(__file__).resolve().parent.parent class HookScriptTests(unittest.TestCase): - def run_cmd(self, cmd, home): + def run_cmd(self, cmd, home, extra_env=None): env = os.environ.copy() + env.pop("CLAUDE_PLUGIN_ROOT", None) env["HOME"] = str(home) env["USERPROFILE"] = str(home) + if extra_env: + env.update(extra_env) return subprocess.run( cmd, cwd=REPO_ROOT, @@ -156,6 +160,60 @@ class HookScriptTests(unittest.TestCase): self.assertNotIn("STATUSLINE SETUP NEEDED", result.stdout) self.assertEqual((claude_dir / ".caveman-active").read_text(), "full") + # Regression for #587/#589 — hook at /src/hooks/ must resolve SKILL.md + # at /skills/caveman/, not the nonexistent /src/skills/. + def test_activate_emits_skill_md_not_fallback_from_repo_layout(self): + with tempfile.TemporaryDirectory(prefix="caveman-hooks-skillpath-") as tmp: + home = Path(tmp) + (home / ".claude").mkdir(parents=True) + + result = self.run_cmd(["node", "src/hooks/caveman-activate.js"], home) + + # Intensity table exists only in SKILL.md, never in the fallback + self.assertIn("## Intensity", result.stdout) + # Default mode is full — table filtered to the active level's row + self.assertIn("| **full** |", result.stdout) + self.assertNotIn("| **lite** |", result.stdout) + + def test_activate_finds_skill_beside_config_dir_hooks(self): + # Standalone layout: hooks at $CLAUDE_CONFIG_DIR/hooks/, skill installed + # at $CLAUDE_CONFIG_DIR/skills/caveman/SKILL.md + with tempfile.TemporaryDirectory(prefix="caveman-hooks-standalone-") as tmp: + home = Path(tmp) + claude_dir = home / ".claude" + hooks_dir = claude_dir / "hooks" + hooks_dir.mkdir(parents=True) + for name in ("caveman-activate.js", "caveman-config.js", "package.json"): + shutil.copy(REPO_ROOT / "src" / "hooks" / name, hooks_dir / name) + skill_dir = claude_dir / "skills" / "caveman" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: caveman\n---\nSTANDALONE MARKER RULESET\n" + ) + + result = self.run_cmd(["node", str(hooks_dir / "caveman-activate.js")], home) + + self.assertIn("STANDALONE MARKER RULESET", result.stdout) + + def test_activate_prefers_claude_plugin_root(self): + with tempfile.TemporaryDirectory(prefix="caveman-hooks-pluginroot-") as tmp: + home = Path(tmp) + (home / ".claude").mkdir(parents=True) + plugin_root = home / "plugin-cache" + skill_dir = plugin_root / "skills" / "caveman" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: caveman\n---\nPLUGIN ROOT MARKER RULESET\n" + ) + + result = self.run_cmd( + ["node", "src/hooks/caveman-activate.js"], + home, + extra_env={"CLAUDE_PLUGIN_ROOT": str(plugin_root)}, + ) + + self.assertIn("PLUGIN ROOT MARKER RULESET", result.stdout) + if __name__ == "__main__": unittest.main()