From a325c64dd4e49ec120fc81e369e952c46e62c531 Mon Sep 17 00:00:00 2001 From: Ijtihed Kilani Date: Sun, 14 Jun 2026 13:16:56 +0300 Subject: [PATCH] feat(cavecrew): add per-agent model overrides --- bin/install.js | 1 + skills/cavecrew/README.md | 20 ++ src/hooks/cavecrew-model-overrides.js | 106 +++++++++ src/hooks/caveman-activate.js | 7 + src/hooks/checksums.sha256 | 1 + src/hooks/install.ps1 | 2 +- src/hooks/install.sh | 2 +- src/hooks/uninstall.ps1 | 2 +- src/hooks/uninstall.sh | 2 +- tests/test_cavecrew_model_overrides.js | 292 +++++++++++++++++++++++++ tests/verify_repo.py | 1 + 11 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 src/hooks/cavecrew-model-overrides.js create mode 100644 tests/test_cavecrew_model_overrides.js diff --git a/bin/install.js b/bin/install.js index 75223dc..b85bec7 100755 --- a/bin/install.js +++ b/bin/install.js @@ -49,6 +49,7 @@ const HOOK_FILES = [ 'caveman-stats.js', 'caveman-statusline.sh', 'caveman-statusline.ps1', + 'cavecrew-model-overrides.js', ]; // ── Argv ─────────────────────────────────────────────────────────────────── diff --git a/skills/cavecrew/README.md b/skills/cavecrew/README.md index e722952..20bb07a 100644 --- a/skills/cavecrew/README.md +++ b/skills/cavecrew/README.md @@ -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 diff --git a/src/hooks/cavecrew-model-overrides.js b/src/hooks/cavecrew-model-overrides.js new file mode 100644 index 0000000..1df5d76 --- /dev/null +++ b/src/hooks/cavecrew-model-overrides.js @@ -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: /hooks/ → plugin root = parent of hooks dir. +function resolvePluginRoot(hookDir) { + return path.resolve(hookDir, '..'); +} + +// Patch the YAML frontmatter of `content` to set `model: `. +// 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 }; diff --git a/src/hooks/caveman-activate.js b/src/hooks/caveman-activate.js index 09ae04c..77a3171 100644 --- a/src/hooks/caveman-activate.js +++ b/src/hooks/caveman-activate.js @@ -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 diff --git a/src/hooks/checksums.sha256 b/src/hooks/checksums.sha256 index 2de97fc..4dcaa79 100644 --- a/src/hooks/checksums.sha256 +++ b/src/hooks/checksums.sha256 @@ -5,3 +5,4 @@ c0b77891d1f8aaef3a7fbf76533ba63a42eaa96431fb7ece611ccff7474de2ba caveman-config be1129b422b7d3edfa8c97df3d8a81f1d1c94cf4e1fd7d6f3a253afebd93fed7 caveman-stats.js d2deff457d0a5d8e1848193e6af6a68a0ebdba4fbdf250889400d5ea231e088f caveman-statusline.sh 1690c639f05940cbff39e0383a27053898b30c224aa651043db29b2842cb524a caveman-statusline.ps1 +eae3f1c22e41c3460a040d71841e4810cc407bb817768fc267591fbef2538cc0 cavecrew-model-overrides.js diff --git a/src/hooks/install.ps1 b/src/hooks/install.ps1 index ef97540..1a9ae1d 100644 --- a/src/hooks/install.ps1 +++ b/src/hooks/install.ps1 @@ -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 } diff --git a/src/hooks/install.sh b/src/hooks/install.sh index 90be58a..deafd8d 100755 --- a/src/hooks/install.sh +++ b/src/hooks/install.sh @@ -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="" diff --git a/src/hooks/uninstall.ps1 b/src/hooks/uninstall.ps1 index 10021f6..364ad4a 100644 --- a/src/hooks/uninstall.ps1 +++ b/src/hooks/uninstall.ps1 @@ -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 diff --git a/src/hooks/uninstall.sh b/src/hooks/uninstall.sh index 8d9af54..8e715a1 100755 --- a/src/hooks/uninstall.sh +++ b/src/hooks/uninstall.sh @@ -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 diff --git a/tests/test_cavecrew_model_overrides.js b/tests/test_cavecrew_model_overrides.js new file mode 100644 index 0000000..5087802 --- /dev/null +++ b/tests/test_cavecrew_model_overrides.js @@ -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); +} diff --git a/tests/verify_repo.py b/tests/verify_repo.py index a123b18..38f89d2 100644 --- a/tests/verify_repo.py +++ b/tests/verify_repo.py @@ -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"])