Skip to content

Commit eba49f8

Browse files
fix(hooks): degrade instead of crashing when a sibling hook file is missing
caveman-activate.js, caveman-mode-tracker.js and caveman-stats.js required './caveman-config' at module top level with no guard. An install missing that one file therefore produced an uncaught MODULE_NOT_FOUND — a raw Node stack trace and exit 1 on every session start and every prompt, which Claude Code surfaces only as: SessionStart:startup hook error Failed with non-blocking status code: node:internal/modules/cjs/loader:1408 caveman-parse.js already wrapped the identical import in try/catch, and caveman-activate.js already treats the optional cavecrew-model-overrides require as best-effort ("any error is swallowed so SessionStart is never blocked") — the risk was understood, just not applied to the mandatory dependency. Same failure class as #801. - activate: falls back to the built-in ruleset it already emits when SKILL.md is unresolvable, so a degraded session still gets rules; only flag persistence is lost. CAVEMAN_DEFAULT_MODE=off still opts out. - mode-tracker: emits nothing and exits 0. Drains stdin before exiting so the parent's payload write does not become a broken pipe (#397). - stats: prints one actionable line and exits non-zero instead of a stack trace the calling hook then reports as an unexplained failure. A nested MODULE_NOT_FOUND thrown from inside a sibling that itself loaded fine is reported as a load error, not as an incomplete install, so the message never points at the wrong cause. Only the first line of error.message is echoed — Node appends a multi-line "Require stack:" block that would reprint the noise this guard removes. The loader is duplicated per entrypoint rather than extracted: it is the guard that makes a missing sibling survivable, so it must not itself be a sibling that can go missing. Fixes #848 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Daekyeong Kim <daekyeong.kim@rebellions.ai>
1 parent 27d5a39 commit eba49f8

5 files changed

Lines changed: 353 additions & 7 deletions

File tree

src/hooks/caveman-activate.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,57 @@
99
const fs = require('fs');
1010
const path = require('path');
1111
const os = require('os');
12-
const { getDefaultMode, safeWriteFlag, recordModeChange, readFlag, VALID_MODES } = require('./caveman-config');
12+
13+
// A sibling hook file can be absent from a real install — a partial plugin
14+
// checkout (#848) or an installer copy list that omits one (#801). A bare
15+
// top-level `require` turns that into an uncaught MODULE_NOT_FOUND: a raw Node
16+
// stack trace and exit 1 on EVERY session start, which Claude Code surfaces as
17+
// "SessionStart:startup hook error". Load defensively and degrade instead —
18+
// the same treatment the optional cavecrew-model-overrides require below
19+
// already gets. Kept inline rather than in a shared module on purpose: this is
20+
// the guard that makes a missing sibling survivable, so it must not itself be
21+
// a sibling that can go missing.
22+
function requireSibling(name) {
23+
try {
24+
return require('./' + name);
25+
} catch (error) {
26+
// First line only: Node appends a multi-line "Require stack:" block to
27+
// .message, and echoing that back reprints the very noise this guard
28+
// exists to remove.
29+
const detail = String((error && error.message) || error).split('\n')[0];
30+
// Distinguish "this file is not there" from a MODULE_NOT_FOUND thrown by
31+
// something the sibling itself requires — the second is a real bug and
32+
// reporting it as a missing install would send users down the wrong path.
33+
const unresolved = error && error.code === 'MODULE_NOT_FOUND' &&
34+
detail.includes("'./" + name + "'");
35+
process.stderr.write(unresolved
36+
? 'caveman: ' + name + '.js is missing from ' + __dirname + ' — the install is ' +
37+
'incomplete. Run `/plugin update caveman`, or rerun install.sh for standalone ' +
38+
'hooks. Continuing with reduced functionality.\n'
39+
: 'caveman: could not load ' + name + '.js — ' + detail +
40+
'. Continuing with reduced functionality.\n');
41+
return null;
42+
}
43+
}
44+
45+
// Stand-ins used when caveman-config is unavailable. SessionStart still emits
46+
// the built-in fallback ruleset — a session with rules and no flag file beats a
47+
// session with neither. Only persistence is lost: no flag write, no mode log,
48+
// and readFlag() reports nothing active.
49+
const DEGRADED_CONFIG = {
50+
// caveman-config.getDefaultMode() consults env, then repo config, then user
51+
// config. Only the env var is reachable without it, and it is also the
52+
// highest-priority source — so an explicit opt-out still wins while degraded.
53+
getDefaultMode: () => (process.env.CAVEMAN_DEFAULT_MODE === 'off' ? 'off' : 'full'),
54+
safeWriteFlag: () => {},
55+
recordModeChange: () => {},
56+
readFlag: () => null,
57+
// Unreachable while degraded — readFlag() above never returns a mode.
58+
VALID_MODES: [],
59+
};
60+
61+
const { getDefaultMode, safeWriteFlag, recordModeChange, readFlag, VALID_MODES } =
62+
requireSibling('caveman-config') || DEGRADED_CONFIG;
1363

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

src/hooks/caveman-mode-tracker.js

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,48 @@ const fs = require('fs');
66
const path = require('path');
77
const os = require('os');
88
const { execFileSync } = require('child_process');
9-
const { getDefaultMode, safeWriteFlag, readFlag, recordModeChange } = require('./caveman-config');
10-
const { parseModeChange, INDEPENDENT_MODES } = require('./caveman-parse');
9+
10+
// Guarded sibling load — see the long comment in caveman-activate.js. Without
11+
// it a missing caveman-config.js or caveman-parse.js (#848, #801) throws an
12+
// uncaught MODULE_NOT_FOUND at module load, which escapes the try/catch inside
13+
// the stdin 'end' handler below and fails the hook on every single prompt.
14+
function requireSibling(name) {
15+
try {
16+
return require('./' + name);
17+
} catch (error) {
18+
const detail = String((error && error.message) || error).split('\n')[0];
19+
const unresolved = error && error.code === 'MODULE_NOT_FOUND' &&
20+
detail.includes("'./" + name + "'");
21+
process.stderr.write(unresolved
22+
? 'caveman: ' + name + '.js is missing from ' + __dirname + ' — the install is ' +
23+
'incomplete. Run `/plugin update caveman`, or rerun install.sh for standalone ' +
24+
'hooks. Prompt tracking is off until then.\n'
25+
: 'caveman: could not load ' + name + '.js — ' + detail +
26+
'. Prompt tracking is off until then.\n');
27+
return null;
28+
}
29+
}
30+
31+
const config = requireSibling('caveman-config');
32+
const parse = requireSibling('caveman-parse');
33+
// Every branch below needs both. Emit nothing and exit clean rather than
34+
// failing the user's prompt: the "Hooks must always exit 0" guarantee has to
35+
// hold at module load too, not only inside the stdin handler.
36+
//
37+
// Drain stdin first instead of exiting on the spot — Claude Code writes the
38+
// hook payload after spawn, and dying before that write lands turns it into a
39+
// broken pipe on the caller's side (#397). `return` at module top level is a
40+
// CommonJS module-wrapper feature, so this exits the hook without reindenting
41+
// everything below it.
42+
if (!config || !parse) {
43+
process.stdin.on('data', () => {});
44+
process.stdin.on('error', () => process.exit(0));
45+
process.stdin.on('end', () => process.exit(0));
46+
return;
47+
}
48+
49+
const { getDefaultMode, safeWriteFlag, readFlag, recordModeChange } = config;
50+
const { parseModeChange, INDEPENDENT_MODES } = parse;
1151

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

src/hooks/caveman-stats.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,33 @@
1010
const fs = require('fs');
1111
const path = require('path');
1212
const os = require('os');
13-
const { readFlag, appendFlag, readHistory, safeWriteFlag, VALID_MODES, MODE_LOG_BASENAME } = require('./caveman-config');
13+
// Guarded sibling load — see the long comment in caveman-activate.js. This
14+
// script is not a hook itself, but the UserPromptSubmit hook shells out to it
15+
// for /caveman-stats, so a missing caveman-config.js (#848, #801) should
16+
// produce one actionable line, not a Node stack trace the hook then reports as
17+
// an unexplained failure.
18+
function requireSibling(name) {
19+
try {
20+
return require('./' + name);
21+
} catch (error) {
22+
const detail = String((error && error.message) || error).split('\n')[0];
23+
const unresolved = error && error.code === 'MODULE_NOT_FOUND' &&
24+
detail.includes("'./" + name + "'");
25+
process.stderr.write(unresolved
26+
? 'caveman-stats: ' + name + '.js is missing from ' + __dirname + ' — the install ' +
27+
'is incomplete. Run `/plugin update caveman`, or rerun install.sh for standalone ' +
28+
'hooks.\n'
29+
: 'caveman-stats: could not load ' + name + '.js — ' + detail + '\n');
30+
return null;
31+
}
32+
}
33+
34+
const config = requireSibling('caveman-config');
35+
// Nothing meaningful is computable without it: every read below goes through
36+
// this module. Exit non-zero so the caller can tell stats did not run.
37+
if (!config) process.exit(1);
38+
39+
const { readFlag, appendFlag, readHistory, safeWriteFlag, VALID_MODES, MODE_LOG_BASENAME } = config;
1440

1541
// Mean per-task savings from benchmarks/results/*.json (avg_savings: 65 across
1642
// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite / ultra /

src/hooks/checksums.sha256

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a package.json
22
6c0f359b4706cc57bcbb6f28cbdd9a82dfaa0135b5b7a23b794999c732335eca caveman-config.js
33
397cf3d243fae04859e0c135f87f456a4972256ae425ee66457da4631ffa509a caveman-parse.js
4-
beb04f58ded18020584c3a77040b70306000e7c7394ffcdca45289b80fed289d caveman-activate.js
5-
2121186528f5a7611d9ee335aac0ba36b70fa0f141f7d89c87cb0e6121d35da0 caveman-mode-tracker.js
6-
f598dde3cc7b701c68547c103a56d566ccc2f75d1c1f3484883ab9f396032b5d caveman-stats.js
4+
94442b49f77f996288619b90be4fa62d2aff855f08a2bd00bc8f2b9c257acb14 caveman-activate.js
5+
ee320f80e55fe0957e7282c38b847511838b16e1ff18792d81d3df7f73be844c caveman-mode-tracker.js
6+
d94885fe205549a6cbe1c442c85523eb25a6b53c10a80fd168f570c10dd23f69 caveman-stats.js
77
4b22120731be5a23f08d0b87d627cd5ac1833d994077d554aa78d7c51a212435 caveman-statusline.sh
88
1690c639f05940cbff39e0383a27053898b30c224aa651043db29b2842cb524a caveman-statusline.ps1
99
9b72e18343a5487acde46d795f4871abfae21212b6eeb853d54981aae260bdf1 cavecrew-model-overrides.js

tests/test_hook_missing_sibling.js

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
#!/usr/bin/env node
2+
// Tests for degraded sibling loading in the hook entrypoints.
3+
// Covers issue #848: caveman-activate.js, caveman-mode-tracker.js and
4+
// caveman-stats.js required './caveman-config' at module top level with no
5+
// guard, so an install that is missing that one file produced an uncaught
6+
// MODULE_NOT_FOUND — a raw Node stack trace and exit 1 on EVERY session start
7+
// and EVERY prompt, reported by Claude Code as an opaque
8+
// "SessionStart:startup hook error ... node:internal/modules/cjs/loader".
9+
// Same failure class as #801 (installer copy list omitting caveman-parse.js).
10+
//
11+
// Run: node tests/test_hook_missing_sibling.js
12+
13+
const path = require('path');
14+
const os = require('os');
15+
const fs = require('fs');
16+
const assert = require('assert');
17+
const { spawnSync } = require('child_process');
18+
19+
const HOOKS_DIR = path.resolve(__dirname, '..', 'src', 'hooks');
20+
const CLEAN_EXIT = 0;
21+
22+
let passed = 0;
23+
let failed = 0;
24+
25+
function test(name, fn) {
26+
try {
27+
fn();
28+
passed++;
29+
console.log(` ✓ ${name}`);
30+
} catch (e) {
31+
failed++;
32+
console.error(` ✗ ${name}`);
33+
console.error(` ${e.message}`);
34+
}
35+
}
36+
37+
console.log('caveman hooks: missing/broken sibling degradation\n');
38+
39+
// A hook dir holding real copies of every hook file, minus `omit`, plus any
40+
// `overrides` written verbatim. Copying rather than symlinking keeps require
41+
// resolution inside the temp dir, so the real src/hooks siblings can never
42+
// satisfy a require the test is trying to break.
43+
function makeHookDir(omit, overrides) {
44+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-sibling-'));
45+
for (const name of fs.readdirSync(HOOKS_DIR)) {
46+
if (name === omit) continue;
47+
const src = path.join(HOOKS_DIR, name);
48+
if (fs.statSync(src).isFile()) fs.copyFileSync(src, path.join(dir, name));
49+
}
50+
for (const [name, body] of Object.entries(overrides || {})) {
51+
fs.writeFileSync(path.join(dir, name), body);
52+
}
53+
return dir;
54+
}
55+
56+
function run(hookDir, hookName, payload) {
57+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-sibling-cfg-'));
58+
const res = spawnSync(process.execPath, [path.join(hookDir, hookName)], {
59+
input: payload === undefined ? '' : JSON.stringify(payload),
60+
// A stale CAVEMAN_DEFAULT_MODE in the developer's shell would otherwise
61+
// change which mode the degraded path reports.
62+
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, CAVEMAN_DEFAULT_MODE: '' },
63+
stdio: ['pipe', 'pipe', 'pipe'],
64+
encoding: 'utf8',
65+
});
66+
res.configDir = configDir;
67+
return res;
68+
}
69+
70+
function cleanup(...dirs) {
71+
for (const d of dirs) fs.rmSync(d, { recursive: true, force: true });
72+
}
73+
74+
// The exact shape that reached users: an unhandled require failure prints the
75+
// loader frame plus a "Require stack:" block. Neither may ever appear again.
76+
function assertNoStackTrace(res) {
77+
const err = res.stderr || '';
78+
assert.ok(!/Require stack:/.test(err), `hook leaked a require stack:\n${err.trim()}`);
79+
assert.ok(
80+
!/node:internal\/modules\/cjs\/loader/.test(err),
81+
`hook leaked a module-loader stack trace:\n${err.trim()}`
82+
);
83+
}
84+
85+
// ---------- SessionStart (caveman-activate.js) ----------
86+
87+
test('activate: missing caveman-config.js still emits the ruleset and exits 0', () => {
88+
const dir = makeHookDir('caveman-config.js');
89+
const res = run(dir, 'caveman-activate.js', { source: 'startup' });
90+
try {
91+
assert.strictEqual(
92+
res.status,
93+
CLEAN_EXIT,
94+
`expected clean exit, got status=${res.status}\nstderr: ${(res.stderr || '').trim()}`
95+
);
96+
assertNoStackTrace(res);
97+
assert.match(
98+
res.stdout || '',
99+
/CAVEMAN MODE ACTIVE/,
100+
'degraded SessionStart must still inject the fallback ruleset'
101+
);
102+
assert.match(
103+
res.stderr || '',
104+
/caveman-config\.js is missing from .* the install is incomplete/,
105+
'stderr must name the missing file and the remedy'
106+
);
107+
} finally {
108+
cleanup(dir, res.configDir);
109+
}
110+
});
111+
112+
test('activate: a sibling that throws at load is reported as a load error, not a missing file', () => {
113+
const dir = makeHookDir(null, {
114+
'caveman-config.js': 'throw new Error("boom from inside caveman-config");\n',
115+
});
116+
const res = run(dir, 'caveman-activate.js', { source: 'startup' });
117+
try {
118+
assert.strictEqual(res.status, CLEAN_EXIT, `expected clean exit, got status=${res.status}`);
119+
assertNoStackTrace(res);
120+
assert.match(res.stdout || '', /CAVEMAN MODE ACTIVE/);
121+
assert.match(
122+
res.stderr || '',
123+
/could not load caveman-config\.js boom from inside caveman-config/,
124+
'a broken sibling must not be misreported as an incomplete install'
125+
);
126+
} finally {
127+
cleanup(dir, res.configDir);
128+
}
129+
});
130+
131+
test('activate: a MODULE_NOT_FOUND raised *inside* the sibling is not misreported as missing', () => {
132+
const dir = makeHookDir(null, {
133+
'caveman-config.js': "require('./definitely-not-a-real-module');\n",
134+
});
135+
const res = run(dir, 'caveman-activate.js', { source: 'startup' });
136+
try {
137+
assert.strictEqual(res.status, CLEAN_EXIT, `expected clean exit, got status=${res.status}`);
138+
assertNoStackTrace(res);
139+
assert.match(
140+
res.stderr || '',
141+
/could not load caveman-config\.js Cannot find module '\.\/definitely-not-a-real-module'/,
142+
'nested resolution failures must surface the inner module, not "install is incomplete"'
143+
);
144+
} finally {
145+
cleanup(dir, res.configDir);
146+
}
147+
});
148+
149+
test('activate: control — complete hook dir writes the flag and emits no warning', () => {
150+
const dir = makeHookDir(null);
151+
const res = run(dir, 'caveman-activate.js', { source: 'startup' });
152+
try {
153+
assert.strictEqual(res.status, CLEAN_EXIT);
154+
assert.match(res.stdout || '', /CAVEMAN MODE ACTIVE/);
155+
assert.strictEqual(
156+
fs.readFileSync(path.join(res.configDir, '.caveman-active'), 'utf8'),
157+
'full',
158+
'the non-degraded path must still persist the mode flag'
159+
);
160+
assert.ok(
161+
!/install is incomplete|could not load/.test(res.stderr || ''),
162+
`healthy install must not warn:\n${(res.stderr || '').trim()}`
163+
);
164+
} finally {
165+
cleanup(dir, res.configDir);
166+
}
167+
});
168+
169+
// ---------- UserPromptSubmit (caveman-mode-tracker.js) ----------
170+
171+
for (const omitted of ['caveman-config.js', 'caveman-parse.js']) {
172+
test(`mode-tracker: missing ${omitted} exits 0 and emits nothing`, () => {
173+
const dir = makeHookDir(omitted);
174+
const res = run(dir, 'caveman-mode-tracker.js', { prompt: 'fix the auth bug' });
175+
try {
176+
assert.strictEqual(
177+
res.status,
178+
CLEAN_EXIT,
179+
`expected clean exit, got status=${res.status}\nstderr: ${(res.stderr || '').trim()}`
180+
);
181+
assertNoStackTrace(res);
182+
assert.strictEqual(
183+
(res.stdout || '').trim(),
184+
'',
185+
'a degraded tracker must inject nothing into the prompt'
186+
);
187+
assert.match(res.stderr || '', /install is incomplete|could not load/);
188+
} finally {
189+
cleanup(dir, res.configDir);
190+
}
191+
});
192+
}
193+
194+
test('mode-tracker: control — complete hook dir still reinforces an active mode', () => {
195+
const dir = makeHookDir(null);
196+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-sibling-cfg-'));
197+
try {
198+
fs.writeFileSync(path.join(configDir, '.caveman-active'), 'full');
199+
const res = spawnSync(process.execPath, [path.join(dir, 'caveman-mode-tracker.js')], {
200+
input: JSON.stringify({ prompt: 'fix the auth bug' }),
201+
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
202+
stdio: ['pipe', 'pipe', 'pipe'],
203+
encoding: 'utf8',
204+
});
205+
assert.strictEqual(res.status, CLEAN_EXIT);
206+
assert.match(res.stdout || '', /CAVEMAN MODE ACTIVE \(full\)/);
207+
} finally {
208+
cleanup(dir, configDir);
209+
}
210+
});
211+
212+
// ---------- /caveman-stats subprocess (caveman-stats.js) ----------
213+
214+
test('stats: missing caveman-config.js prints one actionable line, no stack trace', () => {
215+
const dir = makeHookDir('caveman-config.js');
216+
const res = run(dir, 'caveman-stats.js');
217+
try {
218+
assertNoStackTrace(res);
219+
assert.notStrictEqual(res.status, CLEAN_EXIT, 'stats must report that it did not run');
220+
assert.match(
221+
res.stderr || '',
222+
/caveman-stats: caveman-config\.js is missing from .* the install is incomplete/
223+
);
224+
} finally {
225+
cleanup(dir, res.configDir);
226+
}
227+
});
228+
229+
console.log(`\n${passed} passed, ${failed} failed`);
230+
process.exit(failed === 0 ? 0 : 1);

0 commit comments

Comments
 (0)