Merge PR #527: per-agent cavecrew model overrides (#521)

This commit is contained in:
Julius Brussee
2026-06-14 22:01:44 +02:00
11 changed files with 432 additions and 4 deletions
+1
View File
@@ -49,6 +49,7 @@ const HOOK_FILES = [
'caveman-stats.js',
'caveman-statusline.sh',
'caveman-statusline.ps1',
'cavecrew-model-overrides.js',
];
// ── Argv ───────────────────────────────────────────────────────────────────
+20
View File
@@ -32,6 +32,26 @@ Locate → fix → verify (most common):
Parallel scout: spawn 2-3 `cavecrew-investigator` calls in one message with different angles (defs, callers, tests). Aggregate in main.
## Model overrides
By default, `cavecrew-reviewer` and `cavecrew-investigator` pin `model: haiku` in their frontmatter; `cavecrew-builder` has no `model:` line (uses the API session default). Set env vars in your shell before launching Claude Code to override per-agent:
| Env var | Agent |
|---|---|
| `CAVECREW_REVIEWER_MODEL` | `cavecrew-reviewer` |
| `CAVECREW_BUILDER_MODEL` | `cavecrew-builder` |
| `CAVECREW_INVESTIGATOR_MODEL` | `cavecrew-investigator` |
Example — run reviewer on sonnet, keep others on default:
```sh
export CAVECREW_REVIEWER_MODEL=sonnet
```
Use the same model name strings you'd use in any Claude Code agent frontmatter (e.g. `haiku`, `sonnet`, `opus`).
Overrides patch only the `model:` line in the installed agent's frontmatter; the prompt body is untouched and keeps receiving upstream updates. Plugin installs only — standalone hook installs have no local agent files to patch. Unset or blank = no change. The patch persists in the installed file until the plugin is updated or reinstalled.
## See also
- [`SKILL.md`](./SKILL.md) — full decision matrix and output contracts
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
// cavecrew model overrides — patch installed agent frontmatter from env vars.
//
// Called by caveman-activate.js early in SessionStart so users can pin
// per-agent models without shadow-copying entire agent files.
//
// Env vars:
// CAVECREW_REVIEWER_MODEL → agents/cavecrew-reviewer.md
// CAVECREW_BUILDER_MODEL → agents/cavecrew-builder.md
// CAVECREW_INVESTIGATOR_MODEL → agents/cavecrew-investigator.md
//
// Rules:
// - Unset / blank → no-op.
// - Values containing newlines or control characters → ignored.
// - Existing `model:` line in frontmatter → replaced in-place.
// - No `model:` line → inserted after `tools:` (or before closing `---`).
// - File missing / outside plugin layout → silent no-op.
// - All filesystem errors → silent fail.
const fs = require('fs');
const path = require('path');
const AGENT_ENV_MAP = [
{ envVar: 'CAVECREW_REVIEWER_MODEL', file: path.join('agents', 'cavecrew-reviewer.md') },
{ envVar: 'CAVECREW_BUILDER_MODEL', file: path.join('agents', 'cavecrew-builder.md') },
{ envVar: 'CAVECREW_INVESTIGATOR_MODEL', file: path.join('agents', 'cavecrew-investigator.md') },
];
// Return the plugin root directory given the hooks directory path.
// Plugin layout: <plugin_root>/hooks/<this-file> → plugin root = parent of hooks dir.
function resolvePluginRoot(hookDir) {
return path.resolve(hookDir, '..');
}
// Patch the YAML frontmatter of `content` to set `model: <modelValue>`.
// Returns the patched string, or the original if no frontmatter or already identical.
// Rejects `modelValue` strings that contain newlines or control characters.
function patchFrontmatterModel(content, modelValue) {
// Reject blank or unsafe model strings
if (!modelValue || /[\x00-\x1f\x7f]/.test(modelValue)) return content;
// Must begin with YAML frontmatter delimiter
if (!content.startsWith('---')) return content;
// Find the closing ---
const closeIdx = content.indexOf('\n---', 3);
if (closeIdx === -1) return content;
const fmRaw = content.slice(0, closeIdx); // opening --- through last fm line
const after = content.slice(closeIdx); // \n--- onward (body)
// Preserve original line ending so we don't create mixed CRLF/LF on Windows
const nl = fmRaw.includes('\r\n') ? '\r\n' : '\n';
const modelLine = 'model: ' + modelValue;
const modelRe = /^model:[ \t]*.*$/m;
if (modelRe.test(fmRaw)) {
// Replace existing model: line
const patched = fmRaw.replace(modelRe, modelLine);
if (patched === fmRaw) return content; // already identical
return patched + after;
}
// Insert after tools: line when present; else before closing ---
const toolsMatch = fmRaw.match(/^tools:[ \t]*.*$/m);
if (toolsMatch) {
const toolsEnd = fmRaw.indexOf(toolsMatch[0]) + toolsMatch[0].length;
return fmRaw.slice(0, toolsEnd) + nl + modelLine + fmRaw.slice(toolsEnd) + after;
}
// Append before closing delimiter
return fmRaw + nl + modelLine + after;
}
// Apply all env-var overrides to agent files under `pluginRoot`.
// `env` defaults to process.env; pass an object in tests.
function applyOverrides(pluginRoot, env) {
const envArg = env || process.env;
for (const { envVar, file } of AGENT_ENV_MAP) {
const raw = envArg[envVar];
if (!raw || !raw.trim()) continue;
const modelValue = raw.trim();
if (/[\x00-\x1f\x7f]/.test(modelValue)) continue;
const agentPath = path.join(pluginRoot, file);
let content;
try {
content = fs.readFileSync(agentPath, 'utf8');
} catch (e) {
continue; // missing file or wrong layout → silent no-op
}
const patched = patchFrontmatterModel(content, modelValue);
if (patched === content) continue;
try {
fs.writeFileSync(agentPath, patched, 'utf8');
} catch (e) {
// Silent fail — never block session start
}
}
}
module.exports = { resolvePluginRoot, patchFrontmatterModel, applyOverrides, AGENT_ENV_MAP };
+7
View File
@@ -15,6 +15,13 @@ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.cla
const flagPath = path.join(claudeDir, '.caveman-active');
const settingsPath = path.join(claudeDir, 'settings.json');
// Apply per-agent model overrides from env vars before emitting rules.
// Best-effort: any error is swallowed so SessionStart is never blocked.
try {
const { applyOverrides, resolvePluginRoot } = require('./cavecrew-model-overrides');
applyOverrides(resolvePluginRoot(__dirname));
} catch (e) {}
const mode = getDefaultMode();
// "off" mode — skip activation entirely, don't write flag or emit rules
+1
View File
@@ -5,3 +5,4 @@ c0b77891d1f8aaef3a7fbf76533ba63a42eaa96431fb7ece611ccff7474de2ba caveman-config
be1129b422b7d3edfa8c97df3d8a81f1d1c94cf4e1fd7d6f3a253afebd93fed7 caveman-stats.js
d2deff457d0a5d8e1848193e6af6a68a0ebdba4fbdf250889400d5ea231e088f caveman-statusline.sh
1690c639f05940cbff39e0383a27053898b30c224aa651043db29b2842cb524a caveman-statusline.ps1
eae3f1c22e41c3460a040d71841e4810cc407bb817768fc267591fbef2538cc0 cavecrew-model-overrides.js
+1 -1
View File
@@ -24,7 +24,7 @@ $HooksDir = Join-Path $ClaudeDir "hooks"
$Settings = Join-Path $ClaudeDir "settings.json"
$RepoUrl = "https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks"
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1")
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1", "cavecrew-model-overrides.js")
# Resolve source — works from repo clone or remote
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $null }
+1 -1
View File
@@ -37,7 +37,7 @@ HOOKS_DIR="$CLAUDE_DIR/hooks"
SETTINGS="$CLAUDE_DIR/settings.json"
REPO_URL="https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks"
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh")
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh" "cavecrew-model-overrides.js")
# Resolve source — works from repo clone or curl pipe
SCRIPT_DIR=""
+1 -1
View File
@@ -11,7 +11,7 @@ $HooksDir = Join-Path $ClaudeDir "hooks"
$Settings = Join-Path $ClaudeDir "settings.json"
$FlagFile = Join-Path $ClaudeDir ".caveman-active"
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1")
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1", "cavecrew-model-overrides.js")
# Detect if caveman is installed as a plugin
$PluginInstalled = $false
+1 -1
View File
@@ -10,7 +10,7 @@ HOOKS_DIR="$CLAUDE_DIR/hooks"
SETTINGS="$CLAUDE_DIR/settings.json"
FLAG_FILE="$CLAUDE_DIR/.caveman-active"
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh")
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh" "cavecrew-model-overrides.js")
# Detect if caveman is installed as a plugin (check plugin cache)
PLUGIN_INSTALLED=0
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env node
// Tests for src/hooks/cavecrew-model-overrides.js
// Run: node tests/test_cavecrew_model_overrides.js
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const assert = require('assert');
const { patchFrontmatterModel, resolvePluginRoot, applyOverrides, AGENT_ENV_MAP } =
require('../src/hooks/cavecrew-model-overrides');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(' ✓ ' + name);
} catch (e) {
failed++;
console.error(' ✗ ' + name);
console.error(' ' + e.message);
}
}
// ── patchFrontmatterModel ──────────────────────────────────────────────────
console.log('\npatchFrontmatterModel\n');
const REVIEWER_FM = [
'---',
'name: cavecrew-reviewer',
'description: >',
' Reviewer subagent.',
'tools: [Read, Grep, Bash]',
'model: haiku',
'---',
'',
'Body text.',
].join('\n');
test('replaces existing model: haiku with sonnet in reviewer', () => {
const out = patchFrontmatterModel(REVIEWER_FM, 'sonnet');
assert.ok(out.includes('model: sonnet'), 'new model line missing');
assert.ok(!out.includes('model: haiku'), 'old model line still present');
assert.ok(out.includes('Body text.'), 'body missing');
});
test('preserves all other frontmatter lines', () => {
const out = patchFrontmatterModel(REVIEWER_FM, 'opus');
assert.ok(out.includes('name: cavecrew-reviewer'), 'name line lost');
assert.ok(out.includes('tools: [Read, Grep, Bash]'), 'tools line lost');
assert.ok(out.includes('description: >'), 'description block lost');
});
const INVESTIGATOR_FM = [
'---',
'name: cavecrew-investigator',
'tools: [Read, Grep, Glob, Bash]',
'model: haiku',
'---',
'',
'Investigator body.',
].join('\n');
test('replaces existing model: haiku with opus in investigator', () => {
const out = patchFrontmatterModel(INVESTIGATOR_FM, 'opus');
assert.ok(out.includes('model: opus'), 'new model missing');
assert.ok(!out.includes('model: haiku'), 'old model still present');
assert.ok(out.includes('Investigator body.'), 'body lost');
});
const BUILDER_FM = [
'---',
'name: cavecrew-builder',
'description: >',
' Builder subagent.',
'tools: [Read, Edit, Write, Grep, Glob]',
'---',
'',
'Builder body.',
].join('\n');
test('inserts model: after tools: when no model line exists (builder)', () => {
const out = patchFrontmatterModel(BUILDER_FM, 'sonnet');
assert.ok(out.includes('model: sonnet'), 'model line not inserted');
// Must be inside frontmatter (before body)
const fmClose = out.indexOf('\n---', 3);
const modelPos = out.indexOf('model: sonnet');
assert.ok(modelPos < fmClose, 'model line is outside frontmatter');
// Inserted right after tools: line
const toolsPos = out.indexOf('tools:');
const toolsEnd = out.indexOf('\n', toolsPos);
assert.strictEqual(out.slice(toolsEnd + 1, toolsEnd + 1 + 'model: sonnet'.length), 'model: sonnet',
'model not inserted immediately after tools: line');
assert.ok(out.includes('Builder body.'), 'body lost');
});
test('no-op when content has no frontmatter', () => {
const plain = 'Just some text\nno frontmatter\n';
const out = patchFrontmatterModel(plain, 'sonnet');
assert.strictEqual(out, plain);
});
test('empty model value is no-op (defense-in-depth guard)', () => {
const out = patchFrontmatterModel(REVIEWER_FM, '');
assert.strictEqual(out, REVIEWER_FM, 'empty value should leave file unchanged');
});
test('ignores model value with newline', () => {
const out = patchFrontmatterModel(REVIEWER_FM, 'so\nnnet');
assert.strictEqual(out, REVIEWER_FM, 'should return original unchanged');
});
test('ignores model value with control character', () => {
const out = patchFrontmatterModel(REVIEWER_FM, 'so\x01nnet');
assert.strictEqual(out, REVIEWER_FM, 'should return original unchanged');
});
test('model line already identical → content unchanged', () => {
const out = patchFrontmatterModel(REVIEWER_FM, 'haiku');
assert.strictEqual(out, REVIEWER_FM, 'should be byte-identical when value unchanged');
});
test('no model line and no tools line → inserts before closing ---', () => {
const fm = '---\nname: test\n---\n\nbody\n';
const out = patchFrontmatterModel(fm, 'sonnet');
assert.ok(out.includes('model: sonnet'), 'model line missing');
const fmClose = out.indexOf('\n---', 3);
const modelPos = out.indexOf('model: sonnet');
assert.ok(modelPos < fmClose, 'model line outside frontmatter');
});
test('CRLF files: inserted model line uses CRLF, no mixed endings', () => {
const crlf = REVIEWER_FM.replace(/\n/g, '\r\n');
const out = patchFrontmatterModel(crlf, 'sonnet');
assert.ok(out.includes('model: sonnet'), 'model line missing in CRLF file');
// No bare LF should appear outside CRLF sequences
const strippedCR = out.replace(/\r\n/g, '');
assert.ok(!strippedCR.includes('\n'), 'mixed line endings detected after patch');
});
test('CRLF builder (no model line): inserted model line uses CRLF', () => {
const crlf = BUILDER_FM.replace(/\n/g, '\r\n');
const out = patchFrontmatterModel(crlf, 'sonnet');
assert.ok(out.includes('model: sonnet'), 'model line missing in CRLF builder file');
const strippedCR = out.replace(/\r\n/g, '');
assert.ok(!strippedCR.includes('\n'), 'mixed line endings in CRLF builder patch');
});
// ── resolvePluginRoot ──────────────────────────────────────────────────────
console.log('\nresolvePluginRoot\n');
test('resolves to parent of hooks dir', () => {
const hooksDir = path.join(os.tmpdir(), 'fake-plugin', 'hooks');
const root = resolvePluginRoot(hooksDir);
assert.strictEqual(path.basename(root), 'fake-plugin');
});
// ── applyOverrides ─────────────────────────────────────────────────────────
console.log('\napplyOverrides\n');
function withTmpPlugin(fn) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-override-test-'));
const agentsDir = path.join(tmp, 'agents');
fs.mkdirSync(agentsDir);
try {
fn(tmp, agentsDir);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
test('replaces reviewer model when CAVECREW_REVIEWER_MODEL set', () => {
withTmpPlugin((root, agentsDir) => {
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
assert.ok(out.includes('model: sonnet'), 'reviewer model not patched');
});
});
test('replaces investigator model when CAVECREW_INVESTIGATOR_MODEL set', () => {
withTmpPlugin((root, agentsDir) => {
fs.writeFileSync(path.join(agentsDir, 'cavecrew-investigator.md'), INVESTIGATOR_FM, 'utf8');
applyOverrides(root, { CAVECREW_INVESTIGATOR_MODEL: 'opus' });
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-investigator.md'), 'utf8');
assert.ok(out.includes('model: opus'), 'investigator model not patched');
});
});
test('inserts builder model when CAVECREW_BUILDER_MODEL set and no model line', () => {
withTmpPlugin((root, agentsDir) => {
fs.writeFileSync(path.join(agentsDir, 'cavecrew-builder.md'), BUILDER_FM, 'utf8');
applyOverrides(root, { CAVECREW_BUILDER_MODEL: 'sonnet' });
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-builder.md'), 'utf8');
assert.ok(out.includes('model: sonnet'), 'builder model not inserted');
});
});
test('blank env var is no-op', () => {
withTmpPlugin((root, agentsDir) => {
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: '' });
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
assert.strictEqual(out, REVIEWER_FM, 'blank env var should be no-op');
});
});
test('whitespace-only env var is no-op', () => {
withTmpPlugin((root, agentsDir) => {
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: ' ' });
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
assert.strictEqual(out, REVIEWER_FM, 'whitespace env var should be no-op');
});
});
test('env var with newline in value is ignored', () => {
withTmpPlugin((root, agentsDir) => {
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'so\nnnet' });
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
assert.strictEqual(out, REVIEWER_FM, 'newline in value should be ignored');
});
});
test('env var with control character in value is ignored', () => {
withTmpPlugin((root, agentsDir) => {
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'son\x00net' });
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
assert.strictEqual(out, REVIEWER_FM, 'control char in value should be ignored');
});
});
test('missing agent file is silent no-op', () => {
withTmpPlugin((root) => {
// agents dir exists but reviewer file does not
assert.doesNotThrow(() => {
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
});
});
});
test('missing agents dir (non-plugin layout) is silent no-op', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-nolayout-'));
try {
assert.doesNotThrow(() => {
applyOverrides(tmp, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
});
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('unset env vars → files untouched', () => {
withTmpPlugin((root, agentsDir) => {
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
applyOverrides(root, {});
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
assert.strictEqual(out, REVIEWER_FM, 'file should be unchanged when env unset');
});
});
test('body content preserved after model patch', () => {
withTmpPlugin((root, agentsDir) => {
const content = REVIEWER_FM + '\n\n## Extra\n\nExtra section body.\n';
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), content, 'utf8');
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
assert.ok(out.includes('## Extra'), 'extra body section lost');
assert.ok(out.includes('Extra section body.'), 'body text lost');
});
});
// ── Summary ────────────────────────────────────────────────────────────────
console.log('');
if (failed === 0) {
console.log('All ' + (passed + failed) + ' tests passed.');
process.exit(0);
} else {
console.error(failed + ' test(s) failed.');
process.exit(1);
}
+1
View File
@@ -170,6 +170,7 @@ def verify_manifests_and_syntax() -> None:
run(["node", "--check", "src/hooks/caveman-config.js"])
run(["node", "--check", "src/hooks/caveman-activate.js"])
run(["node", "--check", "src/hooks/caveman-mode-tracker.js"])
run(["node", "--check", "src/hooks/cavecrew-model-overrides.js"])
run(["node", "--check", "bin/install.js"])
run(["node", "--check", "bin/lib/settings.js"])
run(["bash", "-n", "src/hooks/install.sh"])