Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 21 additions & 23 deletions .claude/hooks/caveman-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,25 @@
// - %APPDATA%\caveman\config.json (Windows fallback)
// 3. 'full'

const fs = require('fs');
const path = require('path');
const os = require('os');
const fs = require("fs");
const path = require("path");
const os = require("os");

const VALID_MODES = [
'off', 'lite', 'full', 'ultra',
'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra',
'commit', 'review', 'compress'
];
// biome-ignore format: the whole mode list reads best on one line
const VALID_MODES = ["off", "lite", "full", "ultra", "wenyan-lite", "wenyan", "wenyan-full", "wenyan-ultra", "commit", "review", "compress"];

function getConfigDir() {
if (process.env.XDG_CONFIG_HOME) {
return path.join(process.env.XDG_CONFIG_HOME, 'caveman');
return path.join(process.env.XDG_CONFIG_HOME, "caveman");
}
if (process.platform === 'win32') {
return path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'caveman'
);
if (process.platform === "win32") {
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "caveman");
}
return path.join(os.homedir(), '.config', 'caveman');
return path.join(os.homedir(), ".config", "caveman");
}

function getConfigPath() {
return path.join(getConfigDir(), 'config.json');
return path.join(getConfigDir(), "config.json");
}

function getDefaultMode() {
Expand All @@ -46,7 +40,7 @@ function getDefaultMode() {
// 2. Config file
try {
const configPath = getConfigPath();
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) {
return config.defaultMode.toLowerCase();
}
Expand All @@ -55,7 +49,7 @@ function getDefaultMode() {
}

// 3. Default
return 'full';
return "full";
}

// Symlink-safe flag file write.
Expand Down Expand Up @@ -86,17 +80,21 @@ function safeWriteFlag(flagPath, content) {
try {
if (fs.lstatSync(flagPath).isSymbolicLink()) return;
} catch (e) {
if (e.code !== 'ENOENT') return;
if (e.code !== "ENOENT") return;
}

const tempPath = path.join(flagDir, `.caveman-active.${process.pid}.${Date.now()}`);
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
let fd;
try {
fd = fs.openSync(tempPath, flags, 0o600);
fs.writeSync(fd, String(content));
try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
try {
fs.fchmodSync(fd, 0o600);
} catch (e) {
/* best-effort on Windows */
}
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
Expand Down Expand Up @@ -130,15 +128,15 @@ function readFlag(flagPath) {
if (st.isSymbolicLink() || !st.isFile()) return null;
if (st.size > MAX_FLAG_BYTES) return null;

const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
const flags = fs.constants.O_RDONLY | O_NOFOLLOW;
let fd;
let out;
try {
fd = fs.openSync(flagPath, flags);
const buf = Buffer.alloc(MAX_FLAG_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0);
out = buf.slice(0, n).toString('utf8');
out = buf.slice(0, n).toString("utf8");
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
Expand Down
101 changes: 58 additions & 43 deletions .claude/hooks/caveman-mode-tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,69 +2,79 @@
// caveman — UserPromptSubmit hook to track which caveman mode is active
// Inspects user input for /caveman commands and writes mode to flag file

const fs = require('fs');
const path = require('path');
const os = require('os');
const { getDefaultMode, safeWriteFlag, readFlag } = require('./caveman-config');
const fs = require("fs");
const path = require("path");
const os = require("os");
const { getDefaultMode, safeWriteFlag, readFlag } = require("./caveman-config");

const claudeDir = path.join(process.env.PROJECT_ROOT_DIR || os.homedir(), '.claude');
const flagPath = path.join(claudeDir, '.caveman-active');
const claudeDir = path.join(process.env.PROJECT_ROOT_DIR || os.homedir(), ".claude");
const flagPath = path.join(claudeDir, ".caveman-active");

let input = '';
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => {
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();
const prompt = (data.prompt || "").trim().toLowerCase();

// 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.
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)) {
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)
) {
if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) {
const mode = getDefaultMode();
if (mode !== 'off') {
if (mode !== "off") {
safeWriteFlag(flagPath, mode);
}
}
}

// Match /caveman commands
if (prompt.startsWith('/caveman')) {
if (prompt.startsWith("/caveman")) {
const parts = prompt.split(/\s+/);
const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc.
const arg = parts[1] || '';
const arg = parts[1] || "";

let mode = null;

if (cmd === '/caveman-commit') {
mode = 'commit';
} else if (cmd === '/caveman-review') {
mode = 'review';
} else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') {
mode = 'compress';
} else if (cmd === '/caveman' || cmd === '/caveman:caveman') {
if (arg === 'lite') mode = 'lite';
else if (arg === 'ultra') mode = 'ultra';
else if (arg === 'wenyan-lite') mode = 'wenyan-lite';
else if (arg === 'wenyan' || arg === 'wenyan-full') mode = 'wenyan';
else if (arg === 'wenyan-ultra') mode = 'wenyan-ultra';
if (cmd === "/caveman-commit") {
mode = "commit";
} else if (cmd === "/caveman-review") {
mode = "review";
} else if (cmd === "/caveman-compress" || cmd === "/caveman:caveman-compress") {
mode = "compress";
} else if (cmd === "/caveman" || cmd === "/caveman:caveman") {
if (arg === "lite") mode = "lite";
else if (arg === "ultra") mode = "ultra";
else if (arg === "wenyan-lite") mode = "wenyan-lite";
else if (arg === "wenyan" || arg === "wenyan-full") mode = "wenyan";
else if (arg === "wenyan-ultra") mode = "wenyan-ultra";
else mode = getDefaultMode();
}

if (mode && mode !== 'off') {
if (mode && mode !== "off") {
safeWriteFlag(flagPath, mode);
} else if (mode === 'off') {
try { fs.unlinkSync(flagPath); } catch (e) {}
} else if (mode === "off") {
try {
fs.unlinkSync(flagPath);
} 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)) {
try { fs.unlinkSync(flagPath); } catch (e) {}
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)
) {
try {
fs.unlinkSync(flagPath);
} catch (e) {}
}

// Per-turn reinforcement: emit a structured reminder when caveman is active.
Expand All @@ -78,17 +88,22 @@ 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 INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
const INDEPENDENT_MODES = new Set(["commit", "review", "compress"]);
const activeMode = readFlag(flagPath);
if (activeMode && !INDEPENDENT_MODES.has(activeMode)) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " +
"Drop articles/filler/pleasantries/hedging. Fragments OK. " +
"Code/commits/security: write normal."
}
}));
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext:
"CAVEMAN MODE ACTIVE (" +
activeMode +
"). " +
"Drop articles/filler/pleasantries/hedging. Fragments OK. " +
"Code/commits/security: write normal.",
},
}),
);
}
} catch (e) {
// Silent fail
Expand Down
22 changes: 11 additions & 11 deletions .claude/settings/hooks.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
"type": "command",
"command": "sh -c 'SESSION=$(jq -r \".session_id\" 2>/dev/null); DIR=\"/tmp/claude-$(id -u)\"; mkdir -p \"$DIR\"; printf \"%s\" \"$(git rev-parse --show-toplevel)\" > \"$DIR/.claude_project_root_${SESSION}\"'",
"timeout": 5,
"statusMessage": "Caching project root...",
"statusMessage": "Caching project root..."
},
{
"type": "command",
"command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/caveman-activate.js\"",
"timeout": 5,
"statusMessage": "Loading caveman mode...",
},
],
},
"statusMessage": "Loading caveman mode..."
}
]
}
],
"UserPromptSubmit": [
{
Expand All @@ -26,10 +26,10 @@
"type": "command",
"command": "sh -c 'INPUT=$(cat); SESSION=$(printf \"%s\" \"$INPUT\" | jq -r \".session_id\" 2>/dev/null); ROOT=$(cat \"/tmp/claude-$(id -u)/.claude_project_root_${SESSION}\"); printf \"%s\" \"$INPUT\" | PROJECT_ROOT_DIR=\"$ROOT\" node \"$ROOT/.claude/hooks/caveman-mode-tracker.js\"'",
"timeout": 5,
"statusMessage": "Tracking caveman mode...",
},
],
},
],
},
"statusMessage": "Tracking caveman mode..."
}
]
}
]
}
}
20 changes: 10 additions & 10 deletions .claude/settings/models.jsonc
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"availableModels": [
"fable",
"sonnet",
"opus",
"haiku",
"claude-opus-5",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-sonnet-4-6"
]
"availableModels": [
"fable",
"sonnet",
"opus",
"haiku",
"claude-opus-5",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-sonnet-4-6"
]
}
4 changes: 2 additions & 2 deletions .claude/settings/permissions/additional-dirs.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
// Additional directories that Claude Code can access
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"additionalDirectories": ["/tmp", "/workspaces"],
},
"additionalDirectories": ["/tmp", "/workspaces"]
}
}
10 changes: 5 additions & 5 deletions .claude/settings/permissions/bash.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
"Bash(sort *)",
"Bash(tail *)",
// Search
"Bash(rg *)",
"Bash(rg *)"
// Research
],
"ask": [
Expand All @@ -99,7 +99,7 @@
"Bash(gh pr close *)",
"Bash(gh pr comment *)",
"Bash(gh pr create *)",
"Bash(gh pr edit *)",
"Bash(gh pr edit *)"
],
"deny": [
// Exceptions to generally allowed AI tooling
Expand Down Expand Up @@ -178,7 +178,7 @@
"Bash(usermod *)",
// System Control
"Bash(reboot *)",
"Bash(shutdown *)",
],
},
"Bash(shutdown *)"
]
}
}
4 changes: 2 additions & 2 deletions .claude/settings/permissions/edit.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
// A single leading slash anchors at the settings source, so absolute paths need the `//` prefix
"allow": ["Edit(//tmp/**)", "Edit(//workspaces/**/tmp/**)", "Edit(tmp/**)"],
// Agents are allowed to run any task, but this provides some basic protection against editing a task to do something nefarious that's normally forbidden.
"ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"],
},
"ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"]
}
}
4 changes: 2 additions & 2 deletions .claude/settings/permissions/read.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": ["Read(~/.zshrc)"],
"deny": ["Read(~/Drive/Secrets/**)", "Read(~/.ssh/**)", "Read(**/.env*)", "Read(~/.aws/**)"],
},
"deny": ["Read(~/Drive/Secrets/**)", "Read(~/.ssh/**)", "Read(**/.env*)", "Read(~/.aws/**)"]
}
}
2 changes: 2 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ reviews:
enabled: false
pylint: # when the code contains python, pylint will be run in the CI pipeline, and coderabbit often generates false positives
enabled: false
biome: # biome runs in the CI pipeline through pre-commit, and coderabbit would not find the config in .config/ and fall back to its own defaults
enabled: false
flake8: # we use ruff instead (when we use Python)
enabled: false
poem: false
Expand Down
Loading