fix(hooks): SessionStart SKILL.md path off-by-one

Hook lives at <plugin_root>/src/hooks/ but read SKILL.md via a single
'..' — resolving to nonexistent src/skills/, so every plugin install
silently fell back to the stale hardcoded ruleset (missing language
preservation, no-self-reference, intensity table).

Resolve via candidates in order: $CLAUDE_PLUGIN_ROOT/skills/,
__dirname/../../skills/ (plugin + repo layout), __dirname/../skills/
(standalone $CLAUDE_CONFIG_DIR layout). Sync the two missing rules
into the fallback for installs with no SKILL.md at all.

Fixes #587, fixes #589
This commit is contained in:
AmirF194
2026-07-01 22:42:53 -06:00
parent 25d22f864a
commit 6d71b9ec6f
2 changed files with 86 additions and 8 deletions
+27 -7
View File
@@ -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 = <plugin_root>/hooks/, SKILL.md at <plugin_root>/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 <plugin_root>/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 <plugin_root>/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' +
+59 -1
View File
@@ -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 <root>/src/hooks/ must resolve SKILL.md
# at <root>/skills/caveman/, not the nonexistent <root>/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()