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

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