From c802c33abcda3e9b44a08614a127241283be2c07 Mon Sep 17 00:00:00 2001 From: richm-spp Date: Fri, 15 May 2026 09:48:28 +1000 Subject: [PATCH] fix(hooks): emit suggest-compact via hookSpecificOutput stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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). --- scripts/hooks/suggest-compact.js | 20 ++++++++-- tests/hooks/suggest-compact.test.js | 60 +++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/scripts/hooks/suggest-compact.js b/scripts/hooks/suggest-compact.js index be3f2e79..0e9d5e66 100644 --- a/scripts/hooks/suggest-compact.js +++ b/scripts/hooks/suggest-compact.js @@ -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); diff --git a/tests/hooks/suggest-compact.test.js b/tests/hooks/suggest-compact.test.js index 217304b4..dc3c5c23 100644 --- a/tests/hooks/suggest-compact.test.js +++ b/tests/hooks/suggest-compact.test.js @@ -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):');