fix(hooks): emit suggest-compact via hookSpecificOutput stdout

The threshold and interval suggestions in suggest-compact.js are written
to stderr via log(). Per the Claude Code hooks guide, non-blocking
PreToolUse stderr (exit code 0) is only captured in the debug log — it
does not reach the model. As shipped, the script's nudge to /compact is
silent on Claude Code 2.1.x.

Fix: alongside the existing log() call (kept for debug-log capture),
emit the same suggestion as structured JSON on stdout:

  { hookSpecificOutput: {
      hookEventName: "PreToolUse",
      additionalContext: msg
  } }

This is the documented mechanism for a PreToolUse hook to inject
context into the next model turn without blocking the tool call.

Verified end-to-end on Claude Code 2.1.142 (VSCode native extension,
Windows 11) — the additionalContext now surfaces in the next turn as
a <system-reminder> block. Counter increment and exit code behavior
unchanged.

Tests: 4 new cases in tests/hooks/suggest-compact.test.js covering
stdout JSON at threshold, stdout JSON at +25 interval, silence below
threshold, and stderr-retention for the debug log. Suite goes from
19/19 -> 23/23 (suggest-compact) and full run-all stays clean for
the unaffected suites (the 4 pre-existing Windows broken-symlink
failures in ci/validators, lib/session-manager, and lib/utils are
unrelated to this change).
This commit is contained in:
richm-spp
2026-05-15 09:48:28 +10:00
parent 0e66c838c7
commit c802c33abc
2 changed files with 76 additions and 4 deletions

View File

@@ -19,7 +19,8 @@ const {
getTempDir,
writeFile,
readStdinJson,
log
log,
output
} = require('../lib/utils');
async function resolveSessionId() {
@@ -77,14 +78,25 @@ async function main() {
writeFile(counterFile, String(count));
}
// Suggest compact after threshold tool calls
// Suggest compact after threshold tool calls.
//
// log() writes to stderr (debug log). Per the Claude Code hooks guide,
// non-blocking PreToolUse stderr (exit 0) is only written to the debug log;
// it does not reach the model. To inject a user-facing suggestion without
// blocking the tool call, emit structured JSON to stdout with
// hookSpecificOutput.additionalContext — the documented mechanism for
// PreToolUse hooks to add context to the next model turn.
if (count === threshold) {
log(`[StrategicCompact] ${threshold} tool calls reached - consider /compact if transitioning phases`);
const msg = `[StrategicCompact] ${threshold} tool calls reached - consider /compact if transitioning phases`;
log(msg);
output({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } });
}
// Suggest at regular intervals after threshold (every 25 calls from threshold)
if (count > threshold && (count - threshold) % 25 === 0) {
log(`[StrategicCompact] ${count} tool calls - good checkpoint for /compact if context is stale`);
const msg = `[StrategicCompact] ${count} tool calls - good checkpoint for /compact if context is stale`;
log(msg);
output({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } });
}
process.exit(0);

View File

@@ -366,6 +366,66 @@ function runTests() {
})) passed++;
else failed++;
// ── hookSpecificOutput JSON on stdout ──
// Claude Code 2.1+ drops non-blocking PreToolUse stderr; the suggestion has
// to ride on stdout as { hookSpecificOutput: { additionalContext } } to reach
// the model. These tests pin that contract.
console.log('\nhookSpecificOutput stdout JSON:');
if (test('emits hookSpecificOutput.additionalContext on stdout at threshold', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '49');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0, 'Should exit 0');
assert.ok(result.stdout.trim().length > 0, `Expected stdout payload at threshold. Got: "${result.stdout}"`);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'PreToolUse',
`hookEventName should be PreToolUse. Got: ${JSON.stringify(parsed)}`);
assert.ok(parsed.hookSpecificOutput.additionalContext.includes('50 tool calls reached'),
`additionalContext should include threshold text. Got: ${parsed.hookSpecificOutput.additionalContext}`);
cleanup();
})) passed++;
else failed++;
if (test('emits hookSpecificOutput.additionalContext on stdout at +25 interval', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
// threshold=3, set counter to 27 → next run = 28 → 28-3=25 → interval hit
fs.writeFileSync(counterFile, '27');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
assert.strictEqual(result.code, 0, 'Should exit 0');
assert.ok(result.stdout.trim().length > 0, `Expected stdout payload at interval. Got: "${result.stdout}"`);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
assert.ok(parsed.hookSpecificOutput.additionalContext.includes('28 tool calls'),
`additionalContext should include count. Got: ${parsed.hookSpecificOutput.additionalContext}`);
cleanup();
})) passed++;
else failed++;
if (test('emits no stdout below threshold (silent)', () => {
const { sessionId, cleanup } = createCounterContext();
cleanup();
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '5' });
assert.strictEqual(result.code, 0);
assert.strictEqual(result.stdout.trim(), '',
`Expected empty stdout below threshold. Got: "${result.stdout}"`);
cleanup();
})) passed++;
else failed++;
if (test('still writes [StrategicCompact] to stderr (debug log retained)', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '49');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.ok(result.stderr.includes('[StrategicCompact]'),
`stderr should retain [StrategicCompact] for debug log capture. Got: "${result.stderr}"`);
cleanup();
})) passed++;
else failed++;
// ── Round 64: default session ID fallback ──
console.log('\nDefault session ID fallback (Round 64):');