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):');